Skip to content
Merged
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
2 changes: 1 addition & 1 deletion packages/studio/src/App.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -175,7 +175,7 @@ export function StudioApp() {
reloadPreview: () => setRefreshKey((k) => k + 1),
pendingTimelineEditPathRef,
});
const sdkSession = useSdkSession(projectId, activeCompPath);
const sdkSession = useSdkSession(projectId, activeCompPath ?? "index.html");
const timelineEditing = useTimelineEditing({
projectId,
activeCompPath,
Expand Down
4 changes: 3 additions & 1 deletion packages/studio/src/hooks/useTimelineEditing.ts
Original file line number Diff line number Diff line change
Expand Up @@ -5,7 +5,7 @@
// fallow-ignore-file complexity
import { useCallback, useRef } from "react";
import type { Composition } from "@hyperframes/sdk";
import { runShadowTiming } from "../utils/sdkShadow";
import { runShadowDelete, runShadowTiming } from "../utils/sdkShadow";
import type { TimelineElement } from "../player";
import { usePlayerStore } from "../player";
import { useRazorSplit } from "./useRazorSplit";
Expand Down Expand Up @@ -288,6 +288,7 @@ export function useTimelineEditing({
);
usePlayerStore.getState().setSelectedElementId(null);
reloadPreview();
if (sdkSession) runShadowDelete(sdkSession, element.hfId);
showToast(`Deleted ${label}. Use Undo to restore it.`, "info");
} catch (error) {
const message = error instanceof Error ? error.message : "Failed to delete timeline clip";
Expand All @@ -303,6 +304,7 @@ export function useTimelineEditing({
domEditSaveTimestampRef,
reloadPreview,
isRecordingRef,
sdkSession,
],
);

Expand Down
44 changes: 44 additions & 0 deletions packages/studio/src/utils/sdkShadow.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -108,6 +108,20 @@ describe("sdkShadowDispatch (integration)", () => {
expect(session.getElement("hf-box")?.inlineStyles.color).toBe("#00f");
});

it("does NOT false-mismatch a hyphenated style property (kebab op vs camelCase snapshot)", async () => {
const { sdkShadowDispatch } = await import("./sdkShadow");
const session = await openComposition(BASE_HTML);

const ops: PatchOperation[] = [
{ type: "inline-style", property: "background-color", value: "rgb(255, 79, 88)" },
];
const result = sdkShadowDispatch(session, "hf-box", ops);

expect(result.dispatched).toBe(true);
expect(result.mismatches).toHaveLength(0); // was 1 before the kebab→camel read-back fix
expect(session.getElement("hf-box")?.inlineStyles.backgroundColor).toBe("rgb(255, 79, 88)");
});

it("returns dispatched:false when hfId not found in session", async () => {
const { sdkShadowDispatch } = await import("./sdkShadow");
const session = await openComposition(BASE_HTML);
Expand Down Expand Up @@ -143,6 +157,21 @@ describe("sdkShadowDispatch (integration)", () => {
expect(session.getElement("hf-box")?.attributes["data-name"]).toBe("hero");
});

// fallow-ignore-next-line code-duplication
it("does NOT false-mismatch studio-internal data-hf-* marker attributes", async () => {
const { sdkShadowDispatch } = await import("./sdkShadow");
const session = await openComposition(BASE_HTML);

// path-offset drags emit these already-data-prefixed, SDK-excluded markers.
const ops: PatchOperation[] = [
{ type: "attribute", property: "data-hf-studio-path-offset", value: "true" },
];
const result = sdkShadowDispatch(session, "hf-box", ops);

expect(result.dispatched).toBe(true);
expect(result.mismatches).toHaveLength(0); // filtered, not double-prefixed + flagged
});

it("returns dispatch_error when dispatch throws — does not propagate", async () => {
const { sdkShadowDispatch } = await import("./sdkShadow");
const session = await openComposition(BASE_HTML);
Expand Down Expand Up @@ -301,6 +330,21 @@ tl.to("[data-hf-id=\\"hf-box\\"]", { opacity: "1", duration: 0.5 }, 0);
window.__timelines["t"] = tl;`;
expect(gsapFidelityMismatches(numeric, stringy)).toEqual([]);
});

it("matches the same element across different selector forms when a resolver is given", () => {
// SDK writes [data-hf-id="hf-x"], server writes .x — same element, same tween.
const sdk = `var tl = gsap.timeline({ paused: true });
tl.to("[data-hf-id=\\"hf-x\\"]", { x: 200, duration: 0.8 }, 0.5);
window.__timelines["t"] = tl;`;
const server = `var tl = gsap.timeline({ paused: true });
tl.to(".x", { x: 200, duration: 0.8 }, 0.5);
window.__timelines["t"] = tl;`;
const resolve = (sel: string) => (/hf-x|\.x/.test(sel) ? "hf-x" : sel);
// Without a resolver: selector-form divergence → present/absent mismatch.
expect(gsapFidelityMismatches(sdk, server).length).toBeGreaterThan(0);
// With a resolver: matched by element → no mismatch.
expect(gsapFidelityMismatches(sdk, server, resolve)).toEqual([]);
});
});

describe("runShadowGsapFidelity", () => {
Expand Down
38 changes: 32 additions & 6 deletions packages/studio/src/utils/sdkShadow.ts
Original file line number Diff line number Diff line change
Expand Up @@ -23,6 +23,22 @@ import type { PatchOperation } from "./sourcePatcher";
* Multiple inline-style ops are coalesced into a single setStyle (SDK batches
* style changes naturally). One SDK op is emitted per non-style op.
*/
// "attribute" PatchOperations carry the data- attribute NAME. Studio passes
// some already prefixed (e.g. "data-hf-studio-path-offset") and some bare
// (e.g. "name"); prefix only when needed, never double-prefix.
function attrName(property: string): string {
return property.startsWith("data-") ? property : `data-${property}`;
}

// The SDK element model excludes data-hf-* attributes (document.ts skips them),
// so shadowing studio-internal markers (data-hf-studio-path-offset, etc.) can
// never match — drop those ops from the shadow instead of false-mismatching.
function isShadowableOp(op: PatchOperation): boolean {
if (op.type === "attribute") return !attrName(op.property).startsWith("data-hf-");
if (op.type === "html-attribute") return !op.property.startsWith("data-hf-");
return true;
}

export function patchOpsToSdkEditOps(hfId: string, ops: PatchOperation[]): EditOp[] {
const result: EditOp[] = [];
const styles: Record<string, string | null> = {};
Expand All @@ -38,7 +54,7 @@ export function patchOpsToSdkEditOps(hfId: string, ops: PatchOperation[]): EditO
result.push({
type: "setAttribute",
target: hfId,
name: `data-${op.property}`,
name: attrName(op.property),
value: op.value,
});
} else if (op.type === "html-attribute") {
Expand Down Expand Up @@ -98,17 +114,24 @@ function flattenSnapshot(snap: ElementSnapshot): FlatSnapshot {

type OpFieldResolver = (op: PatchOperation, flat: FlatSnapshot) => OpFields;

// Snapshot inlineStyles are camelCase (CSSStyleDeclaration convention); PatchOperation
// style properties are kebab-case ("background-color"). Convert for read-back, else
// every hyphenated property false-mismatches against a null actual.
function kebabToCamel(prop: string): string {
return prop.replace(/-([a-z])/g, (_, c: string) => c.toUpperCase());
}

const OP_FIELD_RESOLVERS: Record<string, OpFieldResolver> = {
"inline-style": (op, flat) => ({
property: op.property,
expected: op.value,
actual: flat.styles[op.property] ?? null,
actual: flat.styles[kebabToCamel(op.property)] ?? flat.styles[op.property] ?? null,
}),
"text-content": (op, flat) => ({ property: "text", expected: op.value ?? "", actual: flat.text }),
attribute: (op, flat) => ({
property: `data-${op.property}`,
property: attrName(op.property),
expected: op.value ?? null,
actual: flat.attrs[`data-${op.property}`] ?? null,
actual: flat.attrs[attrName(op.property)] ?? null,
}),
"html-attribute": (op, flat) => ({
property: op.property,
Expand Down Expand Up @@ -157,8 +180,11 @@ export function sdkShadowDispatch(
if (!session.getElement(hfId)) {
return { dispatched: false, mismatches: [{ kind: "element_not_found", hfId }] };
}
// Drop studio-internal markers the SDK model can't represent (data-hf-*), so
// canvas-drag/path-offset edits don't false-mismatch on bookkeeping attrs.
const shadowable = ops.filter(isShadowableOp);
try {
const sdkOps = patchOpsToSdkEditOps(hfId, ops);
const sdkOps = patchOpsToSdkEditOps(hfId, shadowable);
session.batch(() => {
for (const op of sdkOps) session.dispatch(op);
});
Expand All @@ -169,7 +195,7 @@ export function sdkShadowDispatch(
};
}
const flat = flattenSnapshot(session.getElement(hfId));
const mismatches = ops
const mismatches = shadowable
.map((op) => checkOpParity(op, flat, hfId))
.filter((m): m is SdkShadowMismatch => m !== null);
return { dispatched: true, mismatches };
Expand Down
96 changes: 78 additions & 18 deletions packages/studio/src/utils/sdkShadowGsapFidelity.ts
Original file line number Diff line number Diff line change
Expand Up @@ -36,10 +36,35 @@ function extractGsapScript(html: string): string | null {
return null;
}

function animById(script: string): Map<string, GsapAnimation> {
function posKey(position: unknown): string {
if (typeof position === "number") return String(position);
const n = Number(position);
return Number.isNaN(n) ? String(position) : String(n);
}

// Key a tween by its RESOLVED target element (not raw selector) + method +
// position. The SDK writer emits [data-hf-id="X"] selectors while the server
// emits class/other selectors for the SAME element; keying by resolved element
// matches them so the diff compares values instead of flagging present/absent.
//
// ponytail: one-tween-per-(element, method, position) assumption — coincident
// tweens (same element+method+position, different props) collapse, last wins,
// so the diff under-reports them. Props can't go in the key (a matched pair
// must share a key for the field-diff to run; raw props would split real value
// drift into present/absent). Not seen in studio-emitted templates; add a
// property-NAME hash to the key if coincident tweens show up in the wild.
function tweenKey(anim: GsapAnimation, resolveSelector?: (sel: string) => string): string {
const sel = resolveSelector ? resolveSelector(anim.targetSelector) : anim.targetSelector;
return `${sel}|${anim.method}|${posKey(anim.position)}`;
}

function animByKey(
script: string,
resolveSelector?: (sel: string) => string,
): Map<string, GsapAnimation> {
const map = new Map<string, GsapAnimation>();
const parsed = parseGsapScriptAcorn(script);
for (const anim of parsed.animations) map.set(anim.id, anim);
for (const anim of parsed.animations) map.set(tweenKey(anim, resolveSelector), anim);
return map;
}

Expand Down Expand Up @@ -73,37 +98,41 @@ function canonicalProps(obj: Record<string, unknown> | undefined): string {
}

/**
* Structurally diff two GSAP scripts by tween id. Reports a tween present in
* one but not the other, and per-field value drift (method, position, duration,
* ease, properties, fromProperties). Comparison is canonical (see above) so
* writer formatting differences do not produce false mismatches.
* Structurally diff two GSAP scripts. Tweens are matched by resolved target
* element + method + position (see tweenKey), so the SDK's [data-hf-id]
* selectors and the server's class selectors for the same element don't
* false-flag present/absent. Reports a tween present in one but not the other,
* and per-field value drift (duration, ease, properties, fromProperties).
* Comparison is canonical so writer formatting differences don't register.
*
* Pass resolveSelector (selector → canonical element id) to enable the
* element-based matching; without it, matching falls back to raw selector.
*/
// fallow-ignore-next-line complexity
export function gsapFidelityMismatches(
sdkScript: string,
serverScript: string,
resolveSelector?: (sel: string) => string,
): SdkShadowMismatch[] {
const sdk = animById(sdkScript);
const server = animById(serverScript);
const sdk = animByKey(sdkScript, resolveSelector);
const server = animByKey(serverScript, resolveSelector);
const mismatches: SdkShadowMismatch[] = [];
const ids = new Set([...sdk.keys(), ...server.keys()]);
for (const id of ids) {
const a = sdk.get(id);
const b = server.get(id);
const keys = new Set([...sdk.keys(), ...server.keys()]);
for (const key of keys) {
const a = sdk.get(key);
const b = server.get(key);
if (!a || !b) {
mismatches.push({
kind: "value_mismatch",
hfId: id,
hfId: key,
property: "tween",
expected: b ? "present" : "absent",
actual: a ? "present" : "absent",
});
continue;
}
// [property, sdk-value, server-value, equal?]
// method + position are part of the key (already equal); compare values.
const fields: Array<[string, unknown, unknown, boolean]> = [
["method", a.method, b.method, a.method === b.method],
["position", a.position, b.position, numericEqual(a.position, b.position)],
["duration", a.duration, b.duration, numericEqual(a.duration, b.duration)],
["ease", a.ease, b.ease, a.ease === b.ease],
[
Expand All @@ -123,7 +152,7 @@ export function gsapFidelityMismatches(
if (!equal) {
mismatches.push({
kind: "value_mismatch",
hfId: id,
hfId: key,
property,
expected: bv == null ? null : JSON.stringify(bv),
actual: av == null ? null : JSON.stringify(av),
Expand Down Expand Up @@ -158,6 +187,33 @@ export function resolveGsapFidelityArgs(
return { before, op: shadowGsapOp, serverScript };
}

// Resolve a CSS selector to a canonical element id (data-hf-id) using the pre-op
// document, so tweens that target the same element via different selectors
// ([data-hf-id="X"] vs .X) match in the fidelity diff. Falls back to the raw
// selector when it can't resolve (DOMParser unavailable, no match, bad selector).
//
// ponytail: first-match heuristic — querySelector returns the FIRST match, so an
// ambiguous selector (e.g. .x shared by two elements) may map to a different id
// than the SDK side's [data-hf-id] target and still flag present/absent. Safe
// for studio templates (one tween per data-hf-id); upgrade to querySelectorAll +
// uniqueness check if ambiguous selectors appear.
function makeSelectorResolver(html: string): (sel: string) => string {
let doc: Document | null = null;
try {
doc = new DOMParser().parseFromString(html, "text/html");
} catch {
doc = null;
}
return (sel) => {
if (!doc) return sel;
try {
return doc.querySelector(sel)?.getAttribute("data-hf-id") ?? sel;
} catch {
return sel;
}
};
}

/**
* Shadow GSAP value fidelity: open a fresh SDK doc from the server's pre-op
* file, apply the same tween op, serialize, and diff the SDK's GSAP script
Expand Down Expand Up @@ -189,7 +245,11 @@ export async function runShadowGsapFidelity(
});
return;
}
const mismatches = gsapFidelityMismatches(sdkScript, serverScript);
const mismatches = gsapFidelityMismatches(
sdkScript,
serverScript,
makeSelectorResolver(beforeHtml),
);
trackStudioEvent("sdk_shadow_dispatch", {
op: "gsap_fidelity",
dispatched: true,
Expand Down
Loading