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 + } }); }