diff --git a/packages/sdk/src/adapters/headless.ts b/packages/sdk/src/adapters/headless.ts
index ed1f09692..300c15f33 100644
--- a/packages/sdk/src/adapters/headless.ts
+++ b/packages/sdk/src/adapters/headless.ts
@@ -1,4 +1,5 @@
import type { PreviewAdapter, ElementAtPointResult, DraftProps } from "./types.js";
+import type { Composition } from "../types.js";
/** Null PreviewAdapter for headless use (agents, CI, server-side rendering). */
class HeadlessPreviewAdapter implements PreviewAdapter {
@@ -17,6 +18,10 @@ class HeadlessPreviewAdapter implements PreviewAdapter {
on(_event: "selection", _handler: (ids: string[]) => void): () => void {
return () => {};
}
+
+ attachSync(_comp: Composition): () => void {
+ return () => {};
+ }
}
export function createHeadlessAdapter(): PreviewAdapter {
diff --git a/packages/sdk/src/adapters/iframe.sync.test.ts b/packages/sdk/src/adapters/iframe.sync.test.ts
new file mode 100644
index 000000000..5d577aac8
--- /dev/null
+++ b/packages/sdk/src/adapters/iframe.sync.test.ts
@@ -0,0 +1,111 @@
+// @vitest-environment happy-dom
+/**
+ * attachSync mirrors every SDK edit (including undo/redo) onto a real live
+ * document — this file needs happy-dom (the package's default vitest
+ * environment is "node") because it exercises iframe.contentDocument.
+ */
+import { describe, it, expect } from "vitest";
+import { createIframePreviewAdapter } from "./iframe.js";
+import { openComposition } from "../session.js";
+
+const BASE_HTML = `
+
+
Hello World
+
+`.trim();
+
+/** A same-origin iframe seeded with the given HTML, ready for contentDocument access. */
+function mountIframe(html: string): HTMLIFrameElement {
+ const iframe = document.createElement("iframe");
+ document.body.appendChild(iframe);
+ iframe.contentDocument!.open();
+ iframe.contentDocument!.write(html);
+ iframe.contentDocument!.close();
+ return iframe;
+}
+
+describe("IframePreviewAdapter.attachSync", () => {
+ it("mirrors comp.getOverrides() onto the iframe immediately on attach", async () => {
+ const iframe = mountIframe(BASE_HTML);
+ const comp = await openComposition(BASE_HTML);
+ comp.setStyle("hf-title", { color: "#f00" }); // edit BEFORE attaching
+
+ const adapter = createIframePreviewAdapter(iframe);
+ adapter.attachSync(comp);
+
+ const liveTitle = iframe.contentDocument!.querySelector(
+ '[data-hf-id="hf-title"]',
+ ) as HTMLElement;
+ expect(liveTitle.style.getPropertyValue("color")).toBe("#f00");
+ });
+
+ it("mirrors a style edit dispatched AFTER attaching", async () => {
+ const iframe = mountIframe(BASE_HTML);
+ const comp = await openComposition(BASE_HTML);
+ const adapter = createIframePreviewAdapter(iframe);
+ adapter.attachSync(comp);
+
+ comp.setStyle("hf-title", { fontSize: "96px" });
+
+ const liveTitle = iframe.contentDocument!.querySelector(
+ '[data-hf-id="hf-title"]',
+ ) as HTMLElement;
+ expect(liveTitle.style.getPropertyValue("font-size")).toBe("96px");
+ });
+
+ it("mirrors setText, setAttribute, and removeElement", async () => {
+ const iframe = mountIframe(BASE_HTML);
+ const comp = await openComposition(BASE_HTML);
+ const adapter = createIframePreviewAdapter(iframe);
+ adapter.attachSync(comp);
+ const liveDoc = iframe.contentDocument!;
+
+ comp.setText("hf-title", "Goodbye");
+ expect(liveDoc.querySelector('[data-hf-id="hf-title"]')?.textContent).toContain("Goodbye");
+
+ comp.setAttribute("hf-title", "data-test", "1");
+ expect(liveDoc.querySelector('[data-hf-id="hf-title"]')?.getAttribute("data-test")).toBe("1");
+
+ comp.removeElement("hf-title");
+ expect(liveDoc.querySelector('[data-hf-id="hf-title"]')).toBeNull();
+ });
+
+ it("mirrors undo — restores the live DOM to the pre-edit state", async () => {
+ const iframe = mountIframe(BASE_HTML);
+ const comp = await openComposition(BASE_HTML);
+ const adapter = createIframePreviewAdapter(iframe);
+ adapter.attachSync(comp);
+ const liveDoc = iframe.contentDocument!;
+
+ comp.setStyle("hf-title", { color: "#f00" });
+ expect(
+ (liveDoc.querySelector('[data-hf-id="hf-title"]') as HTMLElement).style.getPropertyValue(
+ "color",
+ ),
+ ).toBe("#f00");
+
+ comp.undo();
+ expect(
+ (liveDoc.querySelector('[data-hf-id="hf-title"]') as HTMLElement).style.getPropertyValue(
+ "color",
+ ),
+ ).toBe("#fff");
+ });
+
+ it("mirrors redo after undo", async () => {
+ const iframe = mountIframe(BASE_HTML);
+ const comp = await openComposition(BASE_HTML);
+ const adapter = createIframePreviewAdapter(iframe);
+ adapter.attachSync(comp);
+ const liveDoc = iframe.contentDocument!;
+
+ comp.setStyle("hf-title", { color: "#f00" });
+ comp.undo();
+ comp.redo();
+ expect(
+ (liveDoc.querySelector('[data-hf-id="hf-title"]') as HTMLElement).style.getPropertyValue(
+ "color",
+ ),
+ ).toBe("#f00");
+ });
+});
diff --git a/packages/sdk/src/adapters/iframe.ts b/packages/sdk/src/adapters/iframe.ts
index 9143e1746..f5c1595f2 100644
--- a/packages/sdk/src/adapters/iframe.ts
+++ b/packages/sdk/src/adapters/iframe.ts
@@ -37,7 +37,8 @@ import {
readCurrentTranslate,
} from "@hyperframes/core/runtime/position-edits";
import type { PreviewAdapter, ElementAtPointResult, DraftProps } from "./types.js";
-import type { EditOp } from "../types.js";
+import type { EditOp, Composition } from "../types.js";
+import { applyPatchesToDocument, applyOverrideSet } from "../engine/apply-patches.js";
// ─── Pure resolver (testable without a browser) ───────────────────────────────
@@ -511,6 +512,9 @@ class IframePreviewAdapter implements PreviewAdapter {
*/
private _draftPrevInlineTranslate: string | null = null;
+ /** Unsubscribe for the current attachSync subscription, if any. */
+ private _syncDetach: (() => void) | null = null;
+
constructor(iframe: HTMLIFrameElement, dispatch?: (op: EditOp) => void) {
this.iframe = iframe;
this._dispatch = dispatch;
@@ -740,6 +744,31 @@ class IframePreviewAdapter implements PreviewAdapter {
const ids = [...this._selection];
for (const h of this._handlers) h(ids);
}
+
+ /**
+ * Mirror `comp`'s edits onto this.iframe.contentDocument. See the
+ * PreviewAdapter interface doc for the full contract.
+ */
+ attachSync(comp: Composition): () => void {
+ this._syncDetach?.();
+
+ const doc = this.iframe.contentDocument;
+ if (doc) {
+ applyOverrideSet({ document: doc, wrapped: false, stamped: "" }, comp.getOverrides());
+ }
+
+ const unsubscribe = comp.on("patch", ({ patches }) => {
+ const liveDoc = this.iframe.contentDocument;
+ if (!liveDoc) return;
+ applyPatchesToDocument(
+ { document: liveDoc, wrapped: false, stamped: "" },
+ patches.filter((p) => p.path !== "/script/gsap"),
+ );
+ });
+
+ this._syncDetach = unsubscribe;
+ return unsubscribe;
+ }
}
export function createIframePreviewAdapter(
diff --git a/packages/sdk/src/adapters/types.ts b/packages/sdk/src/adapters/types.ts
index 07ece0683..2159ffdf7 100644
--- a/packages/sdk/src/adapters/types.ts
+++ b/packages/sdk/src/adapters/types.ts
@@ -1,4 +1,4 @@
-import type { PersistErrorEvent } from "../types.js";
+import type { PersistErrorEvent, Composition } from "../types.js";
// ─── PersistAdapter ───────────────────────────────────────────────────────────
@@ -73,4 +73,15 @@ export interface PreviewAdapter {
// Stage 8 prep: fired when the preview host changes selection (e.g. user clicks an element).
// Not wired up in stage 7 — callers listen to the session's own selectionchange event instead.
on(event: "selection", handler: (ids: string[]) => void): () => void;
+
+ /**
+ * Mirror this composition's edits onto the adapter's own live document —
+ * an immediate full sync of the composition's CURRENT overrides, then a
+ * subscription that replays every future patch (including undo/redo).
+ * `/script/gsap` patches are never mirrored (re-executing a live