refactor(core): unify composition contract (#2157)

* refactor(core): unify composition contract

* fix(parsers): parse start expressions linearly
This commit is contained in:
James Russo
2026-07-16 02:44:22 -04:00
committed by GitHub
parent 9ed255c0ef
commit 21cb722ebd
36 changed files with 1148 additions and 441 deletions
+3 -3
View File
@@ -1081,7 +1081,7 @@ describe("handleSetTiming GSAP sync (CF2 #15/#16)", () => {
expect(script).not.toMatch(/tl\.to\("#box",[^)]*\}, \d/);
});
it("R5 #7: a clip with BOTH data-duration and data-end keeps data-end in sync on move", () => {
it("canonicalizes a clip carrying both authored duration and derived end", () => {
const parsed = timingDoc(
`data-start="1" data-duration="2" data-end="3"`,
`tl.to("#box", { x: 1, duration: 2 }, 1);`,
@@ -1090,8 +1090,8 @@ describe("handleSetTiming GSAP sync (CF2 #15/#16)", () => {
const el = parsed.document.querySelector('[data-hf-id="hf-box"]');
expect(el?.getAttribute("data-start")).toBe("5");
expect(el?.getAttribute("data-duration")).toBe("2");
// data-end recomputed (5 + 2); the bug left it stale at 3 → inverted clip.
expect(el?.getAttribute("data-end")).toBe("7");
// Source mutations retain authored duration and remove compiler-derived end.
expect(el?.getAttribute("data-end")).toBeNull();
});
});
+9 -4
View File
@@ -321,7 +321,8 @@ describe("setTiming", () => {
const el = parsed.document.querySelector('[data-hf-id="hf-title"]');
expect(el?.getAttribute("data-start")).toBe("1");
// duration was 3 (0→3), so end = 1+3 = 4
expect(el?.getAttribute("data-end")).toBe("4");
expect(el?.getAttribute("data-duration")).toBe("3");
expect(el?.getAttribute("data-end")).toBeNull();
const startPatch = result.forward.find((p) => p.path.endsWith("/start"));
expect(startPatch?.value).toBe(1);
});
@@ -330,12 +331,12 @@ describe("setTiming", () => {
const parsed = fresh();
applyOp(parsed, { type: "setTiming", target: "hf-title", duration: 2 });
const el = parsed.document.querySelector('[data-hf-id="hf-title"]');
expect(el?.getAttribute("data-end")).toBe("2"); // start=0, duration=2 → end=2
expect(el?.getAttribute("data-duration")).toBe("2");
expect(el?.getAttribute("data-end")).toBeNull();
});
it("inverse patches restore original timing", () => {
const parsed = fresh();
const before = serializeDocument(parsed);
const { inverse } = applyOp(parsed, {
type: "setTiming",
target: "hf-title",
@@ -344,7 +345,11 @@ describe("setTiming", () => {
trackIndex: 1,
});
applyPatchesToDocument(parsed, inverse);
expect(serializeDocument(parsed)).toBe(before);
const restored = parsed.document.querySelector('[data-hf-id="hf-title"]');
expect(restored?.getAttribute("data-start")).toBe("0");
expect(restored?.getAttribute("data-duration")).toBeNull();
expect(restored?.getAttribute("data-end")).toBe("3");
expect(restored?.getAttribute("data-track-index")).toBe("0");
});
});
+68 -71
View File
@@ -54,6 +54,7 @@ import {
import { upsertCssRule } from "./cssWriter.js";
import { mintHfId, EXCLUDED_TAGS } from "@hyperframes/core/hf-ids";
import { EDIT_BASE_X_ATTR, EDIT_BASE_Y_ATTR } from "@hyperframes/core/runtime/position-edits";
import { readClipTiming, writeClipTiming } from "@hyperframes/core/composition-contract";
import { parseGsapScriptAcornForWrite } from "@hyperframes/core/gsap-parser-acorn";
import type { GsapAnimation } from "@hyperframes/core/gsap-parser";
import {
@@ -462,84 +463,80 @@ function handleSetTiming(
const el = resolveScoped(parsed.document, id);
if (!el) continue;
const oldStartStr = el.getAttribute("data-start");
const oldEndStr = el.getAttribute("data-end");
const oldDurationStr = el.getAttribute("data-duration");
const oldTrackStr = el.getAttribute("data-track-index");
const oldStart = oldStartStr !== null ? parseFloat(oldStartStr) : null;
const oldEnd = oldEndStr !== null ? parseFloat(oldEndStr) : null;
const oldDurationAttr = oldDurationStr !== null ? parseFloat(oldDurationStr) : null;
// Prefer an explicit data-duration — the attribute clips are authored with and
// the runtime reads — falling back to data-end data-start. Reading only
// data-end left oldDuration null for duration-authored clips, collapsing the
// GSAP duration-scale ratio to 1 and scaling nothing.
const oldDuration =
oldDurationAttr !== null
? oldDurationAttr
: oldStart !== null && oldEnd !== null
? oldEnd - oldStart
: null;
const oldTrack = oldTrackStr !== null ? parseInt(oldTrackStr, 10) : null;
const beforeAttributes = {
start: el.getAttribute("data-start"),
duration: el.getAttribute("data-duration"),
end: el.getAttribute("data-end"),
trackIndex: el.getAttribute("data-track-index"),
layer: el.getAttribute("data-layer"),
};
const oldTiming = readClipTiming(el);
const oldStart = oldTiming.start;
const oldDuration = oldTiming.duration;
const newStart = timing.start ?? oldStart;
const newDuration = timing.duration ?? oldDuration;
if (timing.start !== undefined && newStart !== null) {
const path = timingPath(id, "start");
const p = scalarChange(path, oldStart, newStart);
result.forward.push(p.forward);
result.inverse.push(p.inverse);
el.setAttribute("data-start", String(newStart));
}
writeClipTiming(el, timing);
const afterAttributes = {
start: el.getAttribute("data-start"),
duration: el.getAttribute("data-duration"),
end: el.getAttribute("data-end"),
trackIndex: el.getAttribute("data-track-index"),
layer: el.getAttribute("data-layer"),
};
// Write to whichever timing attribute the clip actually uses. A data-duration
// clip updates data-duration only on a real resize (duration is invariant
// under a move); a data-end clip updates data-end whenever start or duration
// changes (end = start + duration). Writing a fresh data-end beside a stale
// data-duration had no playback effect.
if (oldDurationStr !== null) {
if (timing.duration !== undefined && newDuration !== null) {
const path = timingPath(id, "duration");
const p = scalarChange(path, oldDurationAttr, newDuration);
result.forward.push(p.forward);
result.inverse.push(p.inverse);
el.setAttribute("data-duration", String(newDuration));
const recordAttributeChange = (
path: string,
before: string | null,
after: string | null,
numeric: boolean,
) => {
if (before === after) return;
const toPatchValue = (value: string): string | number => {
if (!numeric) return value;
const parsed = Number(value);
return Number.isFinite(parsed) ? parsed : value;
};
if (after === null) {
if (before === null) return;
const patch = scalarDelete(path, toPatchValue(before));
result.forward.push(patch.forward);
result.inverse.push(patch.inverse);
return;
}
// A clip carrying BOTH data-duration and data-end must keep data-end in
// sync (end = start + duration) on any start/duration change, else the
// stale data-end inverts the clip (end < start) for runtimes that read it.
if (oldEndStr !== null && newStart !== null && newDuration !== null) {
const newEnd = newStart + newDuration;
const endPath = timingPath(id, "end");
const ep = scalarChange(endPath, oldEnd, newEnd);
result.forward.push(ep.forward);
result.inverse.push(ep.inverse);
el.setAttribute("data-end", String(newEnd));
}
} else if (
(timing.duration !== undefined || timing.start !== undefined) &&
newStart !== null &&
newDuration !== null
) {
const newEnd = newStart + newDuration;
// Store the computed end value directly (not the logical duration) so the inverse
// patch is self-contained and doesn't require data-start to be restored first.
const path = timingPath(id, "end");
const p = scalarChange(path, oldEnd, newEnd);
result.forward.push(p.forward);
result.inverse.push(p.inverse);
el.setAttribute("data-end", String(newEnd));
}
const oldValue = before === null ? null : toPatchValue(before);
const newValue = toPatchValue(after);
const patch = scalarChange(path, oldValue, newValue);
result.forward.push(patch.forward);
result.inverse.push(patch.inverse);
};
if (timing.trackIndex !== undefined) {
const newTrack = timing.trackIndex;
const path = timingPath(id, "trackIndex");
const p = scalarChange(path, oldTrack, newTrack);
result.forward.push(p.forward);
result.inverse.push(p.inverse);
el.setAttribute("data-track-index", String(newTrack));
}
recordAttributeChange(
timingPath(id, "start"),
beforeAttributes.start,
afterAttributes.start,
true,
);
recordAttributeChange(
timingPath(id, "duration"),
beforeAttributes.duration,
afterAttributes.duration,
true,
);
recordAttributeChange(timingPath(id, "end"), beforeAttributes.end, afterAttributes.end, true);
recordAttributeChange(
timingPath(id, "trackIndex"),
beforeAttributes.trackIndex,
afterAttributes.trackIndex,
true,
);
recordAttributeChange(
attrPath(id, "data-layer"),
beforeAttributes.layer,
afterAttributes.layer,
false,
);
// Sync GSAP tween positions: the GSAP script is the source of truth at play time —
// the timeline rebuilds from it on every seek. Without this, DOM attribute edits