feat(sdk): ws-a1 — iframe preview adapter (hit-test + selection) (#1489)

* feat(studio): stage 7 step 3c — sdk cutover for inline-style ops

Introduces sdkCutoverPersist(): when STUDIO_SDK_CUTOVER_ENABLED is set,
inline-style PatchOps are routed through the SDK session's in-memory document
model instead of the server patch-element API. The SDK serialize() result is
written back through the same writeProjectFile + editHistory.recordEdit path,
so the on-disk output is identical to the legacy route.

- packages/studio/src/utils/sdkCutover.ts (new): sdkCutoverPersist() +
  shouldUseSdkCutover() guard; domEditSaveTimestampRef.current is stamped on
  each write to suppress the echo file-change reload.
- packages/studio/src/components/editor/manualEditingAvailability.ts: adds
  STUDIO_SDK_CUTOVER_ENABLED flag (default false); changes
  STUDIO_SDK_SHADOW_ENABLED default to false now that cutover is available.
- packages/studio/src/hooks/useSdkSession.ts: adds optional
  domEditSaveTimestampRef param; self-write suppress window (SELF_WRITE_SUPPRESS_MS)
  gates file-change reloads so SDK writes don't echo back as external edits.
- packages/studio/src/App.tsx: passes domEditSaveTimestampRef to useSdkSession
  so the suppress window can gate reloads triggered by SDK cutover writes.
- Test coverage: sdkCutover.test.ts (new, 141 lines) + useDomEditSession.test.ts
  (new, 50 lines) — guard function + happy-path assertions.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>

* fix(studio): force-reload sdk session after undo/redo bypasses suppress window

writeHistoryFile arms the 2 s self-write suppress window, so the
file-change event for an undo/redo write is swallowed and the SDK
in-memory doc stays on pre-undo content. Expose forceReload() from
useSdkSession (s7.4) and call it in useAppHotkeys after a successful
undo/redo that touched the active composition path.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>

Co-authored-by: Miguel Ángel <miguel07alm@protonmail.com>

* feat(studio): s7.5 — delete shadow scaffolding; keep cutover flag (dark launch)

Removes the SDK shadow telemetry: STUDIO_SDK_SHADOW_ENABLED, sdkShadow.ts +
sdkShadowGsapFidelity/GsapKeyframe/Numeric and their tests, the runShadow*
call-sites across the GSAP/timeline hooks, and the onDomEditPersisted shadow
callback in useDomEditSession. Moves patchOpsToSdkEditOps into sdkCutover.ts.

KEEPS STUDIO_SDK_CUTOVER_ENABLED as a dark-launch kill-switch — default false,
enable per-environment via VITE_STUDIO_SDK_CUTOVER_ENABLED=true. shouldUseSdkCutover
stays flag-gated. The stack can merge with zero behavior change; cutover is
validated by flipping the flag, not by removing it.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>

Co-authored-by: Miguel Ángel <miguel07alm@protonmail.com>

* fix(studio): wire onTrySdkPersist to sdkCutoverPersist (cutover was unwired)

Stage 7 s7.5 removed the feature flag and declared cutover 'always-on',
but onTrySdkPersist was never actually passed to useDomEditCommits — the
sdkCutoverPersist function was dead code in production.

Thread sdkSession through useDomEditSession params, build the
onTrySdkPersist closure there (all CutoverDeps are already in scope),
and pass sdkSession from App.tsx. Style/text/attribute/html-attribute
commits now route through SDK dispatch instead of the server patch path.

Co-authored-by: Miguel Ángel <miguel07alm@protonmail.com>

* feat(studio): route element delete through SDK removeElement (§3.1)

Co-authored-by: Miguel Ángel <miguel07alm@protonmail.com>

* feat(studio): route timeline trim/move through SDK setTiming (§3.2)

Co-authored-by: Miguel Ángel <miguel07alm@protonmail.com>

* chore(studio): document CSS-path position cut-over, GSAP-path intentionally deferred (§3.3)

Co-authored-by: Miguel Ángel <miguel07alm@protonmail.com>

* feat(studio): route GSAP tween add/update/delete through SDK (§3.5 PR1)

Co-authored-by: Miguel Ángel <miguel07alm@protonmail.com>

* feat(studio): route GSAP keyframe add through SDK (§3.5 PR2)

Co-authored-by: Miguel Ángel <miguel07alm@protonmail.com>

* fix(studio,core): resolve SDK-cutover review findings

Co-authored-by: Miguel Ángel <miguel07alm@protonmail.com>

* feat(sdk): ws-a1 — iframe preview adapter (hit-test + selection)

Co-authored-by: Miguel Ángel <miguel07alm@protonmail.com>

---------

Co-authored-by: Claude Sonnet 4.6 <noreply@anthropic.com>
Co-authored-by: Miguel Ángel <miguel07alm@protonmail.com>
This commit is contained in:
Vance Ingalls
2026-06-17 16:44:56 -07:00
committed by GitHub
co-authored by Miguel Ángel Claude Sonnet 4.6
parent 377b0368bd
commit b96e8a3072
3 changed files with 306 additions and 0 deletions
+172
View File
@@ -0,0 +1,172 @@
/**
* Unit tests for resolveNearestHfElement (pure resolver — no browser needed).
*
* elementFromPoint itself requires a real browser layout engine. The adapter's
* elementAtPoint() method is therefore NOT tested here; cover it with an
* integration test that mounts a real same-origin iframe (WS-A1 follow-on).
*/
import { describe, it, expect, vi } from "vitest";
import { resolveNearestHfElement } from "./iframe.js";
import type { ElementAtPointResult } from "./types.js";
// ─── Minimal fake element ────────────────────────────────────────────────────
interface FakeEl {
attrs: Record<string, string>;
tagName: string;
parentElement: FakeEl | null;
getAttribute(name: string): string | null;
hasAttribute(name: string): boolean;
}
function fakeEl(
attrs: Record<string, string>,
tagName: string,
parent: FakeEl | null = null,
): FakeEl {
return {
attrs,
tagName,
parentElement: parent,
getAttribute(name) {
return Object.prototype.hasOwnProperty.call(this.attrs, name) ? this.attrs[name] : null;
},
hasAttribute(name) {
return Object.prototype.hasOwnProperty.call(this.attrs, name);
},
};
}
const visible = () => true;
const invisible = () => false;
// ─── Tests ────────────────────────────────────────────────────────────────────
describe("resolveNearestHfElement", () => {
it("returns null for a null input", () => {
expect(resolveNearestHfElement(null, visible)).toBeNull();
});
it("returns the element itself when it carries data-hf-id", () => {
const el = fakeEl({ "data-hf-id": "hf-abc" }, "div");
const result = resolveNearestHfElement(el as unknown as Element, visible);
expect(result).toEqual<ElementAtPointResult>({ id: "hf-abc", tag: "div" });
});
it("walks up to a parent that carries data-hf-id", () => {
const parent = fakeEl({ "data-hf-id": "hf-parent" }, "section");
const child = fakeEl({}, "span", parent);
const result = resolveNearestHfElement(child as unknown as Element, visible);
expect(result).toEqual<ElementAtPointResult>({ id: "hf-parent", tag: "section" });
});
it("returns null when the nearest data-hf-id node is data-hf-root", () => {
const root = fakeEl({ "data-hf-id": "hf-stage", "data-hf-root": "" }, "div");
const child = fakeEl({}, "p", root);
expect(resolveNearestHfElement(child as unknown as Element, visible)).toBeNull();
});
it("returns null when the element itself is data-hf-root", () => {
const root = fakeEl({ "data-hf-id": "hf-stage", "data-hf-root": "" }, "div");
expect(resolveNearestHfElement(root as unknown as Element, visible)).toBeNull();
});
it("returns null when isVisible returns false for the matching element", () => {
const el = fakeEl({ "data-hf-id": "hf-abc" }, "div");
expect(resolveNearestHfElement(el as unknown as Element, invisible)).toBeNull();
});
it("skips an opacity-0 element and returns null (isVisible called on the resolved node)", () => {
// isVisible is only checked on the RESOLVED node, not intermediary nodes.
const parent = fakeEl({ "data-hf-id": "hf-parent" }, "div");
const child = fakeEl({}, "span", parent);
// Make parent invisible
const isVisible = vi.fn((el: Element) => {
const fe = el as unknown as FakeEl;
return fe.attrs["data-hf-id"] !== "hf-parent";
});
expect(resolveNearestHfElement(child as unknown as Element, isVisible)).toBeNull();
// isVisible was called once (on the resolved parent node)
expect(isVisible).toHaveBeenCalledTimes(1);
});
it("returns null when no data-hf-id found in any ancestor", () => {
const grandparent = fakeEl({}, "body");
const parent = fakeEl({}, "div", grandparent);
const child = fakeEl({}, "span", parent);
expect(resolveNearestHfElement(child as unknown as Element, visible)).toBeNull();
});
it("tag is lowercased", () => {
const el = fakeEl({ "data-hf-id": "hf-xyz" }, "DIV");
const result = resolveNearestHfElement(el as unknown as Element, visible);
expect(result?.tag).toBe("div");
});
it("stops at the nearest ancestor — does not continue past first data-hf-id", () => {
const outer = fakeEl({ "data-hf-id": "hf-outer" }, "section");
const inner = fakeEl({ "data-hf-id": "hf-inner" }, "div", outer);
const child = fakeEl({}, "span", inner);
const result = resolveNearestHfElement(child as unknown as Element, visible);
expect(result?.id).toBe("hf-inner");
});
});
// ─── select + on('selection') wiring ─────────────────────────────────────────
// These cover the adapter-level selection state without needing a real iframe.
// We import createIframePreviewAdapter and pass a stub iframe.
import { createIframePreviewAdapter } from "./iframe.js";
function stubIframe() {
return {} as HTMLIFrameElement;
}
describe("IframePreviewAdapter selection", () => {
it("on('selection') fires when select() is called", () => {
const adapter = createIframePreviewAdapter(stubIframe());
const cb = vi.fn();
adapter.on("selection", cb);
adapter.select(["hf-abc"]);
expect(cb).toHaveBeenCalledWith(["hf-abc"]);
});
it("off unsubscribes the handler", () => {
const adapter = createIframePreviewAdapter(stubIframe());
const cb = vi.fn();
const off = adapter.on("selection", cb);
off();
adapter.select(["hf-abc"]);
expect(cb).not.toHaveBeenCalled();
});
it("additive select merges with prior selection", () => {
const adapter = createIframePreviewAdapter(stubIframe());
const cb = vi.fn();
adapter.on("selection", cb);
adapter.select(["hf-a"]);
adapter.select(["hf-b"], { additive: true });
expect(cb).toHaveBeenLastCalledWith(expect.arrayContaining(["hf-a", "hf-b"]));
});
it("non-additive select replaces prior selection", () => {
const adapter = createIframePreviewAdapter(stubIframe());
const cb = vi.fn();
adapter.on("selection", cb);
adapter.select(["hf-a"]);
adapter.select(["hf-b"]);
expect(cb).toHaveBeenLastCalledWith(["hf-b"]);
});
it("multiple handlers all fire", () => {
const adapter = createIframePreviewAdapter(stubIframe());
const cb1 = vi.fn();
const cb2 = vi.fn();
adapter.on("selection", cb1);
adapter.on("selection", cb2);
adapter.select(["hf-abc"]);
expect(cb1).toHaveBeenCalledOnce();
expect(cb2).toHaveBeenCalledOnce();
});
});
+133
View File
@@ -0,0 +1,133 @@
/**
* Same-origin iframe PreviewAdapter — WS-A1 (hit-test + selection).
*
* Requirements:
* - The iframe MUST be same-origin (srcdoc / blob URL). Cross-origin access to
* contentDocument throws a DOMException; this adapter does not guard that —
* the caller is responsible for ensuring same-origin.
* - applyDraft / commitPreview / cancelPreview are WS-A2 scope — stubbed here.
*/
import type { PreviewAdapter, ElementAtPointResult, DraftProps } from "./types.js";
// ─── Pure resolver (testable without a browser) ───────────────────────────────
/**
* Walk from `el` upward through parentElement, looking for the nearest node
* that carries `[data-hf-id]` and is NOT `[data-hf-root]`.
*
* Returns null when:
* - The walk exits the tree without finding `[data-hf-id]`
* - The matching node is `[data-hf-root]` (transparent to hit-testing)
* - `isVisible(node)` returns false for the matching node
*
* Keeping this a pure function (no elementFromPoint, no window access) makes
* it unit-testable in a plain Node environment.
*/
export function resolveNearestHfElement(
el: Element | null,
isVisible: (el: Element) => boolean,
): ElementAtPointResult | null {
let node = el;
while (node !== null) {
const id = node.getAttribute("data-hf-id");
if (id !== null) {
if (node.hasAttribute("data-hf-root")) return null;
if (!isVisible(node)) return null;
return { id, tag: node.tagName.toLowerCase() };
}
node = node.parentElement;
}
return null;
}
// ─── Visibility check ─────────────────────────────────────────────────────────
/**
* Returns true when no element in the ancestor chain (inclusive) has
* computed opacity === 0. Checks ancestors because a parent at opacity:0
* makes the child invisible even if the child's own opacity is 1.
*
* This reflects the current GSAP timeline state (whatever the player has
* seeked to). For atTime values matching the live playhead this is always
* accurate. For speculative times this is NOT seeked — WS-A1 does not mutate
* the timeline; accurate out-of-band opacity queries are WS-G follow-on.
*/
function isOpacityVisible(el: Element, win: Window & typeof globalThis): boolean {
let node: Element | null = el;
while (node !== null) {
const style = win.getComputedStyle(node);
if (parseFloat(style.opacity) === 0) return false;
node = node.parentElement;
}
return true;
}
// ─── IframePreviewAdapter ─────────────────────────────────────────────────────
type SelectionHandler = (ids: string[]) => void;
class IframePreviewAdapter implements PreviewAdapter {
private readonly iframe: HTMLIFrameElement;
private _selection: string[] = [];
private _handlers: SelectionHandler[] = [];
constructor(iframe: HTMLIFrameElement) {
this.iframe = iframe;
}
/**
* Synchronous hit-test. Returns the nearest `[data-hf-id]` element under
* (x, y) in the iframe's coordinate space, or null for a transparent hit
* (root, opacity-0, or nothing at all).
*
* atTime: reflects the GSAP state at the playhead when this is called.
* Seeking to a different time to check visibility is WS-G scope.
*/
elementAtPoint(x: number, y: number, _opts?: { atTime?: number }): ElementAtPointResult | null {
const doc = this.iframe.contentDocument;
if (!doc) return null;
const win = this.iframe.contentWindow as (Window & typeof globalThis) | null;
if (!win) return null;
const hit = doc.elementFromPoint(x, y);
return resolveNearestHfElement(hit, (el) => isOpacityVisible(el, win));
}
// WS-A2 stubs — commitPreview / applyDraft derive the moveElement op --------
applyDraft(_id: string, _props: DraftProps): void {}
commitPreview(): void {}
cancelPreview(): void {}
// Selection -----------------------------------------------------------------
select(ids: string[], opts?: { additive?: boolean }): void {
if (opts?.additive) {
const merged = new Set([...this._selection, ...ids]);
this._selection = [...merged];
} else {
this._selection = [...ids];
}
this._emit();
}
on(event: "selection", handler: SelectionHandler): () => void {
if (event !== "selection") return () => {};
this._handlers.push(handler);
return () => {
this._handlers = this._handlers.filter((h) => h !== handler);
};
}
private _emit(): void {
const ids = [...this._selection];
for (const h of this._handlers) h(ids);
}
}
export function createIframePreviewAdapter(iframe: HTMLIFrameElement): PreviewAdapter {
return new IframePreviewAdapter(iframe);
}
+1
View File
@@ -39,3 +39,4 @@ 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";