diff --git a/packages/core/src/lint/rules/gsap.test.ts b/packages/core/src/lint/rules/gsap.test.ts
index 89a70b5247..7898b66430 100644
--- a/packages/core/src/lint/rules/gsap.test.ts
+++ b/packages/core/src/lint/rules/gsap.test.ts
@@ -3,6 +3,47 @@ import { describe, it, expect } from "vitest";
import { lintHyperframeHtml } from "../hyperframeLinter.js";
describe("GSAP rules", () => {
+ it("errors when window.__timelines is registered BEFORE the fonts.ready build", async () => {
+ const html = `
+
+
+
+`;
+ const result = await lintHyperframeHtml(html);
+ const finding = result.findings.find(
+ (f) => f.code === "gsap_timeline_registered_before_async_build",
+ );
+ expect(finding).toBeDefined();
+ expect(finding?.severity).toBe("error");
+ });
+
+ it("does NOT error when window.__timelines is registered AFTER the fonts.ready build", async () => {
+ const html = `
+
+
+
+`;
+ const result = await lintHyperframeHtml(html);
+ const finding = result.findings.find(
+ (f) => f.code === "gsap_timeline_registered_before_async_build",
+ );
+ expect(finding).toBeUndefined();
+ });
+
it("does NOT error when GSAP animates opacity on a clip element (by id)", async () => {
const html = `
diff --git a/packages/core/src/lint/rules/gsap.ts b/packages/core/src/lint/rules/gsap.ts
index 257d01af69..1a04efd9a4 100644
--- a/packages/core/src/lint/rules/gsap.ts
+++ b/packages/core/src/lint/rules/gsap.ts
@@ -767,6 +767,43 @@ export const gsapRules: LintRule[] = [
return findings;
},
+ // gsap_timeline_registered_before_async_build — registering window.__timelines[id]
+ // BEFORE the timeline is built inside document.fonts.ready (or any async callback)
+ // leaves an EMPTY timeline registered. The runtime's sub-composition readiness gate
+ // treats "key present" as "ready" and nests the child ONCE, while still empty — so the
+ // animation never renders when this composition is mounted as a sub-composition.
+ // Register only AFTER the build completes (the documented async-setup contract).
+ ({ scripts }) => {
+ const findings: HyperframeLintFinding[] = [];
+ for (const script of scripts) {
+ const content = stripJsComments(script.content);
+ const regIdx = content.search(/window\s*\.\s*__timelines\s*\[/);
+ if (regIdx < 0) continue;
+ const fontsReadyIdx = content.search(/document\s*\.\s*fonts\s*\.\s*ready/);
+ if (fontsReadyIdx < 0) continue;
+ // Registering after the async boundary is the correct pattern — skip it.
+ if (regIdx >= fontsReadyIdx) continue;
+ // Confirm the build is actually deferred past the boundary (a tween/build call
+ // appears after document.fonts.ready), i.e. the registered timeline starts empty.
+ const tail = content.slice(fontsReadyIdx);
+ if (!/\.(?:to|from|fromTo)\s*\(|buildEffect\s*\(/.test(tail)) continue;
+ findings.push({
+ code: "gsap_timeline_registered_before_async_build",
+ severity: "error",
+ message:
+ "window.__timelines is assigned BEFORE the timeline is built inside " +
+ "document.fonts.ready. An empty timeline registered early gets nested empty " +
+ "when this composition is used as a sub-composition (the readiness gate treats " +
+ '"key present" as "ready" and never re-nests), so the animation renders blank.',
+ fixHint:
+ "Move the `window.__timelines[id] = tl;` assignment to the END of the " +
+ "document.fonts.ready callback, after the tweens are added. Optionally call " +
+ "window.__hfForceTimelineRebind() right after, to re-nest the populated timeline.",
+ });
+ }
+ return findings;
+ },
+
// gsap_from_opacity_noop — CSS opacity:0 + gsap.from({opacity:0}) = invisible forever
// fallow-ignore-next-line complexity
async ({ styles, scripts, tags }) => {
diff --git a/packages/studio/src/hooks/gsapDragCommit.test.ts b/packages/studio/src/hooks/gsapDragCommit.test.ts
index fab857dd22..cc0cd4479c 100644
--- a/packages/studio/src/hooks/gsapDragCommit.test.ts
+++ b/packages/studio/src/hooks/gsapDragCommit.test.ts
@@ -2,12 +2,12 @@ import { describe, expect, it, beforeEach } from "vitest";
import type { GsapAnimation } from "@hyperframes/core/gsap-parser";
import type { DomEditSelection } from "../components/editor/domEditingTypes";
import {
- commitGsapPositionFromDrag,
commitStaticGsapPosition,
commitStaticGsapRotation,
parkPlayheadOnKeyframe,
type GsapDragCommitCallbacks,
} from "./gsapDragCommit";
+import { commitGsapPositionFromDrag } from "./gsapDragPositionCommit";
import { usePlayerStore } from "../player/store/playerStore";
// Minimal selection whose element has no drag-baseline attributes (origX/Y = 0).
diff --git a/registry/blocks/code-diff/code-diff.html b/registry/blocks/code-diff/code-diff.html
index bf482a8779..03c5a940c3 100644
--- a/registry/blocks/code-diff/code-diff.html
+++ b/registry/blocks/code-diff/code-diff.html
@@ -790,12 +790,21 @@
var root = document.getElementById("root");
window.__timelines = window.__timelines || {};
var tl = gsap.timeline({ paused: true });
- window.__timelines["code-diff"] = tl; // register synchronously
+ // Build inside fonts.ready (glyph metrics must be final), then register.
+ // Register ONLY after the timeline is fully built: the runtime's
+ // sub-composition readiness gate treats "key present" as "ready" and
+ // nests the child once. An empty timeline registered before this build
+ // would be nested empty and never re-nested → blank render when used as
+ // a sub-composition. See the contract in engine frameCapture.ts.
document.fonts.ready.then(function () {
tl.from("#editor", { opacity: 0, scale: 0.985, duration: 0.5, ease: "power2.out" }, 0);
buildEffect(tl, surface, spec);
var dur = parseFloat(root.dataset.duration) || tl.duration();
tl.to({}, { duration: dur }, 0); // pad to full composition length
+ window.__timelines["code-diff"] = tl; // register AFTER setup completes
+ if (typeof window.__hfForceTimelineRebind === "function") {
+ window.__hfForceTimelineRebind(); // re-nest now that we are populated
+ }
});
}
diff --git a/registry/blocks/code-highlight/code-highlight.html b/registry/blocks/code-highlight/code-highlight.html
index 70f7b66a6c..1d6dd76e94 100644
--- a/registry/blocks/code-highlight/code-highlight.html
+++ b/registry/blocks/code-highlight/code-highlight.html
@@ -719,12 +719,21 @@
var root = document.getElementById("root");
window.__timelines = window.__timelines || {};
var tl = gsap.timeline({ paused: true });
- window.__timelines["code-highlight"] = tl; // register synchronously
+ // Build inside fonts.ready (glyph metrics must be final), then register.
+ // Register ONLY after the timeline is fully built: the runtime's
+ // sub-composition readiness gate treats "key present" as "ready" and
+ // nests the child once. An empty timeline registered before this build
+ // would be nested empty and never re-nested → blank render when used as
+ // a sub-composition. See the contract in engine frameCapture.ts.
document.fonts.ready.then(function () {
tl.from("#editor", { opacity: 0, scale: 0.985, duration: 0.5, ease: "power2.out" }, 0);
buildEffect(tl, surface, spec);
var dur = parseFloat(root.dataset.duration) || tl.duration();
tl.to({}, { duration: dur }, 0); // pad to full composition length
+ window.__timelines["code-highlight"] = tl; // register AFTER setup completes
+ if (typeof window.__hfForceTimelineRebind === "function") {
+ window.__hfForceTimelineRebind(); // re-nest now that we are populated
+ }
});
}
diff --git a/registry/blocks/code-morph/code-morph.html b/registry/blocks/code-morph/code-morph.html
index 23d0b808bd..b79c584392 100644
--- a/registry/blocks/code-morph/code-morph.html
+++ b/registry/blocks/code-morph/code-morph.html
@@ -1190,12 +1190,21 @@
var root = document.getElementById("root");
window.__timelines = window.__timelines || {};
var tl = gsap.timeline({ paused: true });
- window.__timelines["code-morph"] = tl; // register synchronously
+ // Build inside fonts.ready (glyph metrics must be final), then register.
+ // Register ONLY after the timeline is fully built: the runtime's
+ // sub-composition readiness gate treats "key present" as "ready" and
+ // nests the child once. An empty timeline registered before this build
+ // would be nested empty and never re-nested → blank render when used as
+ // a sub-composition. See the contract in engine frameCapture.ts.
document.fonts.ready.then(function () {
tl.from("#editor", { opacity: 0, scale: 0.985, duration: 0.5, ease: "power2.out" }, 0);
buildEffect(tl, surface, spec);
var dur = parseFloat(root.dataset.duration) || tl.duration();
tl.to({}, { duration: dur }, 0); // pad to full composition length
+ window.__timelines["code-morph"] = tl; // register AFTER setup completes
+ if (typeof window.__hfForceTimelineRebind === "function") {
+ window.__hfForceTimelineRebind(); // re-nest now that we are populated
+ }
});
}
diff --git a/registry/blocks/code-scroll/code-scroll.html b/registry/blocks/code-scroll/code-scroll.html
index 1484f7c02f..55517a332c 100644
--- a/registry/blocks/code-scroll/code-scroll.html
+++ b/registry/blocks/code-scroll/code-scroll.html
@@ -1534,12 +1534,21 @@
var root = document.getElementById("root");
window.__timelines = window.__timelines || {};
var tl = gsap.timeline({ paused: true });
- window.__timelines["code-scroll"] = tl; // register synchronously
+ // Build inside fonts.ready (glyph metrics must be final), then register.
+ // Register ONLY after the timeline is fully built: the runtime's
+ // sub-composition readiness gate treats "key present" as "ready" and
+ // nests the child once. An empty timeline registered before this build
+ // would be nested empty and never re-nested → blank render when used as
+ // a sub-composition. See the contract in engine frameCapture.ts.
document.fonts.ready.then(function () {
tl.from("#editor", { opacity: 0, scale: 0.985, duration: 0.5, ease: "power2.out" }, 0);
buildEffect(tl, surface, spec);
var dur = parseFloat(root.dataset.duration) || tl.duration();
tl.to({}, { duration: dur }, 0); // pad to full composition length
+ window.__timelines["code-scroll"] = tl; // register AFTER setup completes
+ if (typeof window.__hfForceTimelineRebind === "function") {
+ window.__hfForceTimelineRebind(); // re-nest now that we are populated
+ }
});
}
diff --git a/registry/blocks/code-typing/code-typing.html b/registry/blocks/code-typing/code-typing.html
index 62edf975e1..5a30750005 100644
--- a/registry/blocks/code-typing/code-typing.html
+++ b/registry/blocks/code-typing/code-typing.html
@@ -737,12 +737,21 @@
var root = document.getElementById("root");
window.__timelines = window.__timelines || {};
var tl = gsap.timeline({ paused: true });
- window.__timelines["code-typing"] = tl; // register synchronously
+ // Build inside fonts.ready (glyph metrics must be final), then register.
+ // Register ONLY after the timeline is fully built: the runtime's
+ // sub-composition readiness gate treats "key present" as "ready" and
+ // nests the child once. An empty timeline registered before this build
+ // would be nested empty and never re-nested → blank render when used as
+ // a sub-composition. See the contract in engine frameCapture.ts.
document.fonts.ready.then(function () {
tl.from("#editor", { opacity: 0, scale: 0.985, duration: 0.5, ease: "power2.out" }, 0);
buildEffect(tl, surface, spec);
var dur = parseFloat(root.dataset.duration) || tl.duration();
tl.to({}, { duration: dur }, 0); // pad to full composition length
+ window.__timelines["code-typing"] = tl; // register AFTER setup completes
+ if (typeof window.__hfForceTimelineRebind === "function") {
+ window.__hfForceTimelineRebind(); // re-nest now that we are populated
+ }
});
}