diff --git a/packages/sdk/package.json b/packages/sdk/package.json
index af3b292ce..74692b73d 100644
--- a/packages/sdk/package.json
+++ b/packages/sdk/package.json
@@ -29,10 +29,6 @@
"./adapters/headless": {
"import": "./src/adapters/headless.ts",
"types": "./src/adapters/headless.ts"
- },
- "./adapters/http": {
- "import": "./src/adapters/http.ts",
- "types": "./src/adapters/http.ts"
}
},
"publishConfig": {
@@ -53,10 +49,6 @@
"./adapters/headless": {
"import": "./dist/adapters/headless.js",
"types": "./dist/adapters/headless.d.ts"
- },
- "./adapters/http": {
- "import": "./dist/adapters/http.js",
- "types": "./dist/adapters/http.d.ts"
}
},
"main": "./dist/index.js",
diff --git a/packages/sdk/src/adapters/http.test.ts b/packages/sdk/src/adapters/http.test.ts
deleted file mode 100644
index 19c39bb98..000000000
--- a/packages/sdk/src/adapters/http.test.ts
+++ /dev/null
@@ -1,313 +0,0 @@
-/**
- * Unit tests for createHttpAdapter.
- *
- * Mocks global `fetch` to verify URL construction, method/headers, error routing,
- * and flush semantics without a real server.
- */
-
-import { describe, it, expect, vi, beforeEach, afterEach } from "vitest";
-import { createHttpAdapter } from "./http.js";
-
-const BASE = "/api/projects/proj-abc";
-
-// ── fetch mock helpers ────────────────────────────────────────────────────────
-
-function stubFetch(
- handler: (url: string, init?: RequestInit) => { ok: boolean; status?: number; body?: unknown },
-): ReturnType {
- const mock = vi.fn(async (url: string, init?: RequestInit) => {
- const r = handler(url, init);
- return {
- ok: r.ok,
- status: r.status ?? (r.ok ? 200 : 500),
- json: async () => r.body ?? {},
- };
- });
- vi.stubGlobal("fetch", mock);
- return mock;
-}
-
-beforeEach(() => {
- stubFetch(() => ({ ok: true, body: { content: "" } }));
-});
-
-afterEach(() => {
- vi.unstubAllGlobals();
-});
-
-// ── read() ────────────────────────────────────────────────────────────────────
-
-describe("read()", () => {
- it("fetches the correct URL with ?optional=1", async () => {
- const mock = stubFetch(() => ({ ok: true, body: { content: "" } }));
- const adapter = createHttpAdapter({ projectFilesUrl: BASE });
- await adapter.read("comp.html");
- expect(mock).toHaveBeenCalledWith(
- `${BASE}/files/${encodeURIComponent("comp.html")}?optional=1`,
- );
- });
-
- it("returns content on success", async () => {
- stubFetch(() => ({ ok: true, body: { content: "hello" } }));
- const adapter = createHttpAdapter({ projectFilesUrl: BASE });
- expect(await adapter.read("comp.html")).toBe("hello");
- });
-
- it("returns undefined when response body lacks content field", async () => {
- stubFetch(() => ({ ok: true, body: {} }));
- const adapter = createHttpAdapter({ projectFilesUrl: BASE });
- expect(await adapter.read("missing.html")).toBeUndefined();
- });
-
- it("returns undefined on non-ok response", async () => {
- stubFetch(() => ({ ok: false, status: 404 }));
- const adapter = createHttpAdapter({ projectFilesUrl: BASE });
- expect(await adapter.read("gone.html")).toBeUndefined();
- });
-});
-
-// ── write() ───────────────────────────────────────────────────────────────────
-
-describe("write()", () => {
- it("PUTs to the correct URL with text/plain body", async () => {
- const mock = stubFetch(() => ({ ok: true }));
- const adapter = createHttpAdapter({ projectFilesUrl: BASE });
- await adapter.write("comp.html", "new");
- expect(mock).toHaveBeenCalledWith(
- `${BASE}/files/${encodeURIComponent("comp.html")}`,
- expect.objectContaining({
- method: "PUT",
- headers: expect.objectContaining({ "Content-Type": "text/plain" }),
- body: "new",
- }),
- );
- });
-
- it("fires persist:error on non-ok response without throwing", async () => {
- stubFetch(() => ({ ok: false, status: 503 }));
- const adapter = createHttpAdapter({ projectFilesUrl: BASE });
- const onError = vi.fn();
- adapter.on("persist:error", onError);
- await expect(adapter.write("comp.html", "x")).resolves.toBeUndefined();
- expect(onError).toHaveBeenCalledWith(
- expect.objectContaining({ error: expect.objectContaining({ message: "HTTP 503" }) }),
- );
- });
-
- it("fires persist:error on network error without throwing", async () => {
- vi.stubGlobal("fetch", vi.fn().mockRejectedValue(new TypeError("network down")));
- const adapter = createHttpAdapter({ projectFilesUrl: BASE });
- const onError = vi.fn();
- adapter.on("persist:error", onError);
- await expect(adapter.write("comp.html", "x")).resolves.toBeUndefined();
- expect(onError).toHaveBeenCalledWith(
- expect.objectContaining({
- error: expect.objectContaining({ message: expect.stringContaining("network down") }),
- }),
- );
- });
-
- it("does not fire persist:error on success", async () => {
- stubFetch(() => ({ ok: true }));
- const adapter = createHttpAdapter({ projectFilesUrl: BASE });
- const onError = vi.fn();
- adapter.on("persist:error", onError);
- await adapter.write("comp.html", "x");
- expect(onError).not.toHaveBeenCalled();
- });
-});
-
-// ── headers option ───────────────────────────────────────────────────────────
-
-describe("headers option", () => {
- it("merges static headers into every PUT request", async () => {
- const mock = stubFetch(() => ({ ok: true }));
- const adapter = createHttpAdapter({
- projectFilesUrl: BASE,
- headers: { Authorization: "Bearer tok" },
- });
- await adapter.write("comp.html", "x");
- expect(mock).toHaveBeenCalledWith(
- expect.any(String),
- expect.objectContaining({
- headers: expect.objectContaining({ Authorization: "Bearer tok" }),
- }),
- );
- });
-
- it("calls a headers function lazily on each write", async () => {
- const mock = stubFetch(() => ({ ok: true }));
- let n = 0;
- const adapter = createHttpAdapter({
- projectFilesUrl: BASE,
- headers: () => ({ Authorization: `Bearer tok${++n}` }),
- });
- await adapter.write("comp.html", "a");
- await adapter.write("comp.html", "b");
- const calls = mock.mock.calls.filter((c) => c[1]?.method === "PUT");
- expect((calls[0][1]?.headers as Record)?.["Authorization"]).toBe("Bearer tok1");
- expect((calls[1][1]?.headers as Record)?.["Authorization"]).toBe("Bearer tok2");
- });
-});
-
-// ── flush() ───────────────────────────────────────────────────────────────────
-
-describe("flush()", () => {
- it("resolves immediately when no writes are in flight", async () => {
- const adapter = createHttpAdapter({ projectFilesUrl: BASE });
- await expect(adapter.flush()).resolves.toBeUndefined();
- });
-
- it("waits for an in-flight write before resolving", async () => {
- let resolveFetch!: () => void;
- vi.stubGlobal(
- "fetch",
- vi.fn().mockImplementation(async (_url: string, init?: RequestInit) => {
- if (init?.method === "PUT") {
- await new Promise((r) => {
- resolveFetch = r;
- });
- }
- return { ok: true, status: 200, json: async () => ({}) };
- }),
- );
- const adapter = createHttpAdapter({ projectFilesUrl: BASE });
- void adapter.write("comp.html", "x"); // intentionally not awaited
- await Promise.resolve(); // let path-queue microtask fire so doWrite starts
- let flushed = false;
- const flushDone = adapter.flush().then(() => {
- flushed = true;
- });
- expect(flushed).toBe(false);
- resolveFetch();
- await flushDone;
- expect(flushed).toBe(true);
- });
-
- it("waits for two concurrent in-flight writes before resolving", async () => {
- const resolvers: Array<() => void> = [];
- vi.stubGlobal(
- "fetch",
- vi.fn().mockImplementation(async (_url: string, init?: RequestInit) => {
- if (init?.method === "PUT") {
- await new Promise((r) => resolvers.push(r));
- }
- return { ok: true, status: 200, json: async () => ({}) };
- }),
- );
- const adapter = createHttpAdapter({ projectFilesUrl: BASE });
- void adapter.write("a.html", "1");
- void adapter.write("b.html", "2");
- await Promise.resolve(); // let both start
- await Promise.resolve();
- let flushed = false;
- const flushDone = adapter.flush().then(() => {
- flushed = true;
- });
- expect(flushed).toBe(false);
- resolvers[0]();
- await Promise.resolve();
- expect(flushed).toBe(false); // still waiting for second write
- resolvers[1]();
- await flushDone;
- expect(flushed).toBe(true);
- });
-});
-
-// ── listVersions() / loadFrom() ───────────────────────────────────────────────
-
-describe("listVersions()", () => {
- it("returns empty array (server versioning not exposed by this adapter)", async () => {
- const adapter = createHttpAdapter({ projectFilesUrl: BASE });
- expect(await adapter.listVersions("comp.html")).toEqual([]);
- });
-});
-
-describe("loadFrom()", () => {
- it("returns undefined (server versioning not exposed by this adapter)", async () => {
- const adapter = createHttpAdapter({ projectFilesUrl: BASE });
- expect(await adapter.loadFrom("comp.html", "v1")).toBeUndefined();
- });
-});
-
-// ── write() — per-path serialization ─────────────────────────────────────────
-
-describe("write() — per-path serialization", () => {
- it("serializes concurrent writes to the same path (second waits for first)", async () => {
- const starts: number[] = [];
- let resolveFirst!: () => void;
- let callCount = 0;
- vi.stubGlobal(
- "fetch",
- vi.fn().mockImplementation(async (_url: string, init?: RequestInit) => {
- if (init?.method === "PUT") {
- const n = ++callCount;
- starts.push(n);
- if (n === 1) await new Promise((r) => (resolveFirst = r));
- }
- return { ok: true, status: 200, json: async () => ({}) };
- }),
- );
- const adapter = createHttpAdapter({ projectFilesUrl: BASE });
- const write1 = adapter.write("comp.html", "v1");
- await Promise.resolve(); // let write1 start
- const write2 = adapter.write("comp.html", "v2");
- await Promise.resolve(); // let write2 attempt to start
- expect(starts).toEqual([1]); // write2 has NOT started yet
- resolveFirst();
- await write1;
- await write2;
- expect(starts).toEqual([1, 2]); // write2 started only after write1 finished
- });
-
- it("does not block writes to different paths", async () => {
- const starts: string[] = [];
- let resolveFirst!: () => void;
- let callCount = 0;
- vi.stubGlobal(
- "fetch",
- vi.fn().mockImplementation(async (url: string, init?: RequestInit) => {
- if (init?.method === "PUT") {
- const n = ++callCount;
- starts.push(`${n}:${url.split("/").pop()}`);
- if (n === 1) await new Promise((r) => (resolveFirst = r));
- }
- return { ok: true, status: 200, json: async () => ({}) };
- }),
- );
- const adapter = createHttpAdapter({ projectFilesUrl: BASE });
- const write1 = adapter.write("a.html", "v1");
- await Promise.resolve();
- void adapter.write("b.html", "v2"); // different path — must not wait for write1
- await Promise.resolve();
- expect(starts.length).toBe(2); // both started concurrently
- resolveFirst();
- await write1;
- });
-});
-
-// ── on() / unsubscribe ────────────────────────────────────────────────────────
-
-describe("on() / unsubscribe", () => {
- it("unsubscribe removes the listener", async () => {
- stubFetch(() => ({ ok: false, status: 500 }));
- const adapter = createHttpAdapter({ projectFilesUrl: BASE });
- const onError = vi.fn();
- const unsub = adapter.on("persist:error", onError);
- unsub();
- await adapter.write("comp.html", "x");
- expect(onError).not.toHaveBeenCalled();
- });
-
- it("multiple listeners all fire", async () => {
- stubFetch(() => ({ ok: false, status: 500 }));
- const adapter = createHttpAdapter({ projectFilesUrl: BASE });
- const a = vi.fn();
- const b = vi.fn();
- adapter.on("persist:error", a);
- adapter.on("persist:error", b);
- await adapter.write("comp.html", "x");
- expect(a).toHaveBeenCalledOnce();
- expect(b).toHaveBeenCalledOnce();
- });
-});
diff --git a/packages/sdk/src/adapters/http.ts b/packages/sdk/src/adapters/http.ts
deleted file mode 100644
index 03faf680a..000000000
--- a/packages/sdk/src/adapters/http.ts
+++ /dev/null
@@ -1,111 +0,0 @@
-import type { PersistAdapter, PersistVersionEntry } from "./types.js";
-import type { PersistErrorEvent } from "../types.js";
-
-export interface HttpAdapterOptions {
- /**
- * Base URL for the project files REST API, no trailing slash.
- * E.g. "/api/projects/proj-abc"
- */
- projectFilesUrl: string;
- /**
- * Extra headers to include on every PUT write request.
- * Pass a function to compute them lazily (e.g. to refresh a bearer token on each request).
- * Useful for cross-origin or CLI contexts where ambient cookies are not available.
- */
- headers?: HeadersInit | (() => HeadersInit);
-}
-
-class HttpAdapter implements PersistAdapter {
- private readonly baseUrl: string;
- private readonly extraHeaders?: HttpAdapterOptions["headers"];
- private readonly errorListeners: Array<(e: PersistErrorEvent) => void> = [];
- private readonly inflightWrites = new Set>();
- private readonly pathQueues = new Map>();
-
- constructor(opts: HttpAdapterOptions) {
- this.baseUrl = opts.projectFilesUrl;
- this.extraHeaders = opts.headers;
- }
-
- async read(path: string): Promise {
- 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 };
- return typeof data.content === "string" ? data.content : undefined;
- }
-
- /**
- * Enqueue a write for path. Same-path writes are serialized via pathQueues
- * so concurrent saves never interleave. Each write is a single-shot PUT —
- * on network error or non-2xx response, persist:error fires and the write
- * is not retried. Retry is the caller's responsibility.
- */
- async write(path: string, content: string): Promise {
- const prev = this.pathQueues.get(path) ?? Promise.resolve();
- const p = prev.then(() => this.doWrite(path, content));
- this.pathQueues.set(
- path,
- p.catch(() => {}),
- );
- this.inflightWrites.add(p);
- try {
- await p;
- } finally {
- this.inflightWrites.delete(p);
- }
- }
-
- private async doWrite(path: string, content: string): Promise {
- const url = `${this.baseUrl}/files/${encodeURIComponent(path)}`;
- let res: Response;
- try {
- const extra =
- typeof this.extraHeaders === "function" ? this.extraHeaders() : this.extraHeaders;
- res = await fetch(url, {
- method: "PUT",
- headers: { "Content-Type": "text/plain", ...extra },
- body: content,
- });
- } catch (err) {
- this.fireError(String(err), err);
- return;
- }
- if (!res.ok) {
- this.fireError(`HTTP ${res.status}`);
- }
- }
-
- async flush(): Promise {
- await Promise.all([...this.inflightWrites]);
- }
-
- /** Server-side versioning is not exposed by this adapter; returns [] intentionally. */
- async listVersions(_path: string): Promise {
- return [];
- }
-
- /** Server-side versioning is not exposed by this adapter; returns undefined intentionally. */
- async loadFrom(_path: string, _versionKey: string): Promise {
- return undefined;
- }
-
- on(event: "persist:error", handler: (e: PersistErrorEvent) => void): () => void {
- if (event !== "persist:error") return () => {};
- this.errorListeners.push(handler);
- return () => {
- const idx = this.errorListeners.indexOf(handler);
- if (idx !== -1) this.errorListeners.splice(idx, 1);
- };
- }
-
- private fireError(message: string, cause?: unknown): void {
- const error: PersistErrorEvent["error"] =
- cause !== undefined ? { message, cause } : { message };
- for (const l of this.errorListeners) l({ error });
- }
-}
-
-export function createHttpAdapter(opts: HttpAdapterOptions): PersistAdapter {
- return new HttpAdapter(opts);
-}
diff --git a/packages/sdk/src/engine/mutate.test.ts b/packages/sdk/src/engine/mutate.test.ts
index 03b8f209d..4334787c4 100644
--- a/packages/sdk/src/engine/mutate.test.ts
+++ b/packages/sdk/src/engine/mutate.test.ts
@@ -565,3 +565,69 @@ describe("setCompositionMetadata data-* channel", () => {
expect(root?.getAttribute("style")).toContain("width: 1920px");
});
});
+
+// ─── reorderElements ─────────────────────────────────────────────────────────
+
+describe("reorderElements", () => {
+ it("sets zIndex on each entry", () => {
+ const parsed = fresh();
+ applyOp(parsed, {
+ type: "reorderElements",
+ entries: [
+ { target: "hf-title", zIndex: 2 },
+ { target: "hf-logo", zIndex: 1 },
+ ],
+ });
+ const title = parsed.document.querySelector("[data-hf-id='hf-title']") as HTMLElement | null;
+ const logo = parsed.document.querySelector("[data-hf-id='hf-logo']") as HTMLElement | null;
+ expect(title?.style.zIndex).toBe("2");
+ expect(logo?.style.zIndex).toBe("1");
+ });
+
+ it("inverse restores original zIndex values", () => {
+ const parsed = fresh();
+ const before = serializeDocument(parsed);
+ const { inverse } = applyOp(parsed, {
+ type: "reorderElements",
+ entries: [{ target: "hf-title", zIndex: 5 }],
+ });
+ applyPatchesToDocument(parsed, inverse);
+ expect(serializeDocument(parsed)).toBe(before);
+ });
+
+ it("validateOp returns ok:true for existing targets", () => {
+ const r = validateOp(fresh(), {
+ type: "reorderElements",
+ entries: [{ target: "hf-title", zIndex: 1 }],
+ });
+ expect(r.ok).toBe(true);
+ });
+
+ it("validateOp returns E_TARGET_NOT_FOUND for unknown target", () => {
+ const r = validateOp(fresh(), {
+ type: "reorderElements",
+ entries: [{ target: "hf-unknown", zIndex: 1 }],
+ });
+ expect(r.ok).toBe(false);
+ if (!r.ok) expect(r.code).toBe("E_TARGET_NOT_FOUND");
+ });
+
+ it("duplicate target collapses to last-wins and inverse restores cleanly", () => {
+ const parsed = fresh();
+ const before = serializeDocument(parsed);
+ const { forward, inverse } = applyOp(parsed, {
+ type: "reorderElements",
+ entries: [
+ { target: "hf-title", zIndex: 2 },
+ { target: "hf-title", zIndex: 9 },
+ ],
+ });
+ const title = parsed.document.querySelector("[data-hf-id='hf-title']") as HTMLElement | null;
+ expect(title?.style.zIndex).toBe("9"); // last write wins
+ expect(forward.length).toBe(1); // one patch, not two on the same path
+ // Inverse must be applied in reverse order (session reverses single-dispatch
+ // inverse) to land back on the original, not the intermediate "2".
+ applyPatchesToDocument(parsed, [...inverse].reverse());
+ expect(serializeDocument(parsed)).toBe(before);
+ });
+});
diff --git a/packages/sdk/src/engine/mutate.ts b/packages/sdk/src/engine/mutate.ts
index 8b4761053..6091a7a19 100644
--- a/packages/sdk/src/engine/mutate.ts
+++ b/packages/sdk/src/engine/mutate.ts
@@ -261,6 +261,8 @@ export function applyOp(parsed: ParsedDocument, op: EditOp): MutationResult {
return handleMoveElement(parsed, targets(op.target), op.x, op.y);
case "removeElement":
return handleRemoveElement(parsed, targets(op.target));
+ case "reorderElements":
+ return handleReorderElements(parsed, op.entries);
case "setCompositionMetadata":
return handleSetCompositionMetadata(parsed, op);
case "setVariableValue":
@@ -608,6 +610,23 @@ function handleRemoveElement(parsed: ParsedDocument, ids: HfId[]): MutationResul
return result;
}
+function handleReorderElements(
+ parsed: ParsedDocument,
+ entries: Array<{ target: HfId; zIndex: number }>,
+): MutationResult {
+ const result: MutationResult = { forward: [], inverse: [] };
+ // Last write wins per target — a duplicated target collapses to one zIndex
+ // patch instead of emitting redundant same-path patches in one dispatch.
+ const lastByTarget = new Map();
+ for (const { target, zIndex } of entries) lastByTarget.set(target, zIndex);
+ for (const [target, zIndex] of lastByTarget) {
+ const sub = handleSetStyle(parsed, [target], { zIndex: String(zIndex) });
+ result.forward.push(...sub.forward);
+ result.inverse.push(...sub.inverse);
+ }
+ return result;
+}
+
// fallow-ignore-next-line complexity
function handleSetCompositionMetadata(
parsed: ParsedDocument,
@@ -1189,6 +1208,19 @@ export function validateOp(parsed: ParsedDocument, op: EditOp): CanResult {
);
return CAN_OK;
}
+ case "reorderElements": {
+ if (op.entries.length === 0) return CAN_OK;
+ const missing = op.entries
+ .map((e) => e.target)
+ .filter((id) => resolveScoped(parsed.document, id) === null);
+ if (missing.length > 0)
+ return canErr(
+ "E_TARGET_NOT_FOUND",
+ `Element(s) not found: ${missing.join(", ")}.`,
+ "Verify the id against comp.getElements() or comp.find().",
+ );
+ return CAN_OK;
+ }
case "setVariableValue":
if (findRoot(parsed.document) === null)
return canErr("E_NO_ROOT", "Composition root element not found.");
diff --git a/packages/sdk/src/index.ts b/packages/sdk/src/index.ts
index 60acf256d..72eefc9e3 100644
--- a/packages/sdk/src/index.ts
+++ b/packages/sdk/src/index.ts
@@ -37,6 +37,4 @@ export type { PersistAdapter, PreviewAdapter, PersistVersionEntry } from "./adap
// Concrete adapter factories (browser-safe — Node-only fs adapter: @hyperframes/sdk/adapters/fs).
export { createMemoryAdapter } from "./adapters/memory.js";
export { createHeadlessAdapter } from "./adapters/headless.js";
-export { createHttpAdapter } from "./adapters/http.js";
-export type { HttpAdapterOptions } from "./adapters/http.js";
export { createIframePreviewAdapter, resolveNearestHfElement } from "./adapters/iframe.js";
diff --git a/packages/sdk/src/types.ts b/packages/sdk/src/types.ts
index 66a453ad7..70cc2792f 100644
--- a/packages/sdk/src/types.ts
+++ b/packages/sdk/src/types.ts
@@ -82,6 +82,11 @@ export type EditOp =
| { type: "setHold"; target: HfId | HfId[]; hold: ElasticHold }
| { type: "moveElement"; target: HfId | HfId[]; x: number; y: number }
| { type: "removeElement"; target: HfId | HfId[] }
+ | {
+ type: "reorderElements";
+ /** Each entry sets inline zIndex on one element. Positioning is unchanged — z-index only takes effect on non-static elements, so the caller must ensure the target is positioned. */
+ entries: Array<{ target: HfId; zIndex: number }>;
+ }
| { type: "setClassStyle"; selector: string; styles: Record }
| { type: "setCompositionMetadata"; width?: number; height?: number; duration?: number }
| { type: "setVariableValue"; id: string; value: string | number | boolean }
diff --git a/packages/studio/src/hooks/useDomEditCommits.ts b/packages/studio/src/hooks/useDomEditCommits.ts
index 6cc5c4659..1ef3fe7ab 100644
--- a/packages/studio/src/hooks/useDomEditCommits.ts
+++ b/packages/studio/src/hooks/useDomEditCommits.ts
@@ -82,6 +82,8 @@ export interface UseDomEditCommitsParams {
) => Promise;
/** Stage 7 §3.1: called before the server-side delete path; returns true if SDK handled it. */
onTrySdkDelete?: (hfId: string, originalContent: string, targetPath: string) => Promise;
+ /** Resolver-shadow tripwire for z-index reorder targets (telemetry-only, decoupled from cutover). */
+ onReorderShadow?: (targets: string[]) => void;
}
export function useDomEditCommits({
@@ -105,6 +107,7 @@ export function useDomEditCommits({
forceReloadSdkSession,
onTrySdkPersist,
onTrySdkDelete,
+ onReorderShadow,
}: UseDomEditCommitsParams) {
const resolveImportedFontAsset = useCallback(
(fontFamilyValue: string): ImportedFontAsset | null => {
@@ -324,6 +327,7 @@ export function useDomEditCommits({
reloadPreview,
clearDomSelection,
onTrySdkDelete,
+ onReorderShadow,
forceReloadSdkSession,
commitPositionPatchToHtml,
});
diff --git a/packages/studio/src/hooks/useDomEditSession.ts b/packages/studio/src/hooks/useDomEditSession.ts
index 53551ef94..81e0f50a0 100644
--- a/packages/studio/src/hooks/useDomEditSession.ts
+++ b/packages/studio/src/hooks/useDomEditSession.ts
@@ -6,7 +6,7 @@ import type { PatchTarget } from "../utils/sourcePatcher";
import type { SidebarTab } from "../components/sidebar/LeftSidebar";
import type { Composition } from "@hyperframes/sdk";
import { sdkCutoverPersist, sdkDeletePersist } from "../utils/sdkCutover";
-import { runResolverShadow } from "../utils/sdkResolverShadow";
+import { runResolverShadow, recordResolverParity } from "../utils/sdkResolverShadow";
import { useAskAgentModal } from "./useAskAgentModal";
import { useDomSelection } from "./useDomSelection";
import { usePreviewInteraction } from "./usePreviewInteraction";
@@ -270,6 +270,14 @@ export function useDomEditSession({
compositionPath: activeCompPath,
})
: undefined,
+ // Resolver shadow for the z-index reorder edit: it takes the server path (no
+ // SDK persist), but the tripwire is decoupled from cutover — record whether
+ // the SDK resolves each reordered element (the reorderElements op's targets).
+ onReorderShadow: sdkSession
+ ? (targets: string[]) => {
+ for (const target of targets) recordResolverParity(sdkSession, target, "reorderElements");
+ }
+ : undefined,
});
// ── Wiring: selection sync, GSAP cache, preview sync, selection handlers ──
diff --git a/packages/studio/src/hooks/useElementLifecycleOps.ts b/packages/studio/src/hooks/useElementLifecycleOps.ts
index 1ee885a62..f064d8c18 100644
--- a/packages/studio/src/hooks/useElementLifecycleOps.ts
+++ b/packages/studio/src/hooks/useElementLifecycleOps.ts
@@ -28,6 +28,8 @@ interface UseElementLifecycleOpsParams {
clearDomSelection: () => void;
/** Route delete through SDK when session resolves the hf-id; returns true if handled. */
onTrySdkDelete?: (hfId: string, originalContent: string, targetPath: string) => Promise;
+ /** Resolver-shadow tripwire for the reordered targets (telemetry-only, decoupled from cutover). */
+ onReorderShadow?: (targets: string[]) => void;
/** Resync the SDK session after a server-fallback delete. */
forceReloadSdkSession?: () => void;
commitPositionPatchToHtml: (
@@ -49,6 +51,7 @@ export function useElementLifecycleOps({
reloadPreview,
clearDomSelection,
onTrySdkDelete,
+ onReorderShadow,
forceReloadSdkSession,
commitPositionPatchToHtml,
onElementDeleted,
@@ -168,6 +171,11 @@ export function useElementLifecycleOps({
}>,
) => {
if (entries.length === 0) return;
+ // Resolver shadow (telemetry-only, decoupled from cutover): record whether
+ // the SDK resolves each reordered element — the reorderElements op's targets.
+ onReorderShadow?.(
+ entries.map((e) => readHfId(e.element)).filter((id): id is string => id != null),
+ );
const coalesceKey = `z-reorder:${entries.map((e) => e.id ?? e.selector ?? e.element.getAttribute("data-hf-id") ?? "el").join(":")}`;
for (let i = 0; i < entries.length; i++) {
const entry = entries[i];
@@ -202,7 +210,7 @@ export function useElementLifecycleOps({
).catch(() => undefined);
}
},
- [commitPositionPatchToHtml],
+ [commitPositionPatchToHtml, onReorderShadow],
);
return {
diff --git a/packages/studio/src/hooks/useSdkSession.ts b/packages/studio/src/hooks/useSdkSession.ts
index e7b8c7e23..9f3a79943 100644
--- a/packages/studio/src/hooks/useSdkSession.ts
+++ b/packages/studio/src/hooks/useSdkSession.ts
@@ -1,11 +1,32 @@
import { useState, useEffect, useCallback } from "react";
import type { MutableRefObject } from "react";
import { openComposition } from "@hyperframes/sdk";
-import { createHttpAdapter } from "@hyperframes/sdk/adapters/http";
import type { Composition } from "@hyperframes/sdk";
import { readStudioFileChangePath } from "../components/editor/manualEdits";
import { isSelfWriteEcho } from "./sdkSelfWriteRegistry";
+/**
+ * Read a project file's content, or undefined on a non-2xx (optional read).
+ * Replaces the removed SDK http adapter's `read()` — the only thing Studio used
+ * it for (Studio is the sole writer, so the adapter's write path was dead).
+ */
+async function readProjectFileOptional(
+ projectId: string,
+ path: string,
+): Promise {
+ // Reject traversal / NUL before building the request URL — `path` is a
+ // user-influenced composition path (mirrors the guard in timelineEditingHelpers,
+ // and closes the CodeQL client-side-request-forgery flag). encodeURIComponent
+ // already confines both values to single segments of this same-origin URL.
+ if (path.includes("\0") || path.includes("..")) return undefined;
+ const res = await fetch(
+ `/api/projects/${encodeURIComponent(projectId)}/files/${encodeURIComponent(path)}?optional=1`,
+ );
+ if (!res.ok) return undefined;
+ const data = (await res.json()) as { content?: string };
+ return typeof data.content === "string" ? data.content : undefined;
+}
+
/**
* True when an external file-change payload targets the active composition and
* the SDK session must be re-opened to pick up the new content.
@@ -18,8 +39,8 @@ export function shouldReloadSdkSession(payload: unknown, activeCompPath: string
/**
* Stage 7 Step 3a — SDK session wired to the active composition.
*
- * Creates an SDK Composition backed by createHttpAdapter on every
- * (projectId, activeCompPath) change, disposes the old one on cleanup, and
+ * Creates an SDK Composition (reading the file via the project files API) on
+ * every (projectId, activeCompPath) change, disposes the old one on cleanup, and
* re-opens it when the active composition file changes on disk (code editor,
* agent, or server-side patch) so the in-memory linkedom document never goes
* stale. The session has NO persist queue — Studio is the sole file writer; see
@@ -81,10 +102,7 @@ export function useSdkSession(
useEffect(() => {
if (!activeCompPath) return;
const compPath = activeCompPath;
- const readAdapter =
- projectId != null
- ? createHttpAdapter({ projectFilesUrl: `/api/projects/${projectId}` })
- : null;
+ const readProjectId = projectId ?? null;
const handler = (payload?: unknown) => {
if (!shouldReloadSdkSession(payload, compPath)) return;
const withinWindow =
@@ -96,13 +114,12 @@ export function useSdkSession(
const payloadContent = readFileChangeContent(payload);
// Prefer payload content; otherwise re-read so the decision is by IDENTITY
// (an undo's reverted bytes won't match a registered self-write → reload).
- if (payloadContent != null || !readAdapter) {
+ if (payloadContent != null || readProjectId == null) {
decide(payloadContent);
return;
}
- readAdapter
- .read(compPath)
- .then((c) => decide(typeof c === "string" ? c : null))
+ readProjectFileOptional(readProjectId, compPath)
+ .then((c) => decide(c ?? null))
.catch(() => decide(null));
};
if (import.meta.hot) {
@@ -126,11 +143,7 @@ export function useSdkSession(
let cancelled = false;
const compRef = { current: null as Composition | null };
- const adapter = createHttpAdapter({
- projectFilesUrl: `/api/projects/${projectId}`,
- });
- adapter
- .read(activeCompPath)
+ readProjectFileOptional(projectId, activeCompPath)
.then(async (content) => {
if (cancelled || typeof content !== "string") return;
// No persist queue: Studio's writeProjectFile (via sdkCutover's