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
1 change: 1 addition & 0 deletions .fallowrc.jsonc
Original file line number Diff line number Diff line change
Expand Up @@ -47,6 +47,7 @@
"packages/**/__goldens__/**",
"registry/**",
"examples/**",
"packages/sdk/examples/**",
".github/workflows/fixtures/**",
// Auto-generated TS client for the HeyGen cloud API. Regenerated by
// experiment-framework/scripts/generate_hyperframes_cli_client.py via
Expand Down
35 changes: 25 additions & 10 deletions bun.lock

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

2 changes: 1 addition & 1 deletion package.json
Original file line number Diff line number Diff line change
Expand Up @@ -11,7 +11,7 @@
"type": "module",
"scripts": {
"dev": "bun run studio",
"build": "bun run --filter @hyperframes/core build && bun run --filter '@hyperframes/{core,engine,producer,player,studio,shader-transitions,aws-lambda,gcp-cloud-run}' build && bun run --filter @hyperframes/cli build",
"build": "bun run --filter @hyperframes/core build && bun run --filter '@hyperframes/{core,engine,producer,player,studio,shader-transitions,aws-lambda,gcp-cloud-run,sdk}' build && bun run --filter @hyperframes/cli build",
"build:producer": "bun run --filter @hyperframes/producer build",
"studio": "bun run --filter @hyperframes/studio dev",
"build:hyperframes-runtime": "bun run --filter @hyperframes/core build:hyperframes-runtime",
Expand Down
1 change: 1 addition & 0 deletions packages/core/src/lint/rules/adapters.test.ts
Original file line number Diff line number Diff line change
@@ -1,3 +1,4 @@
// fallow-ignore-file code-duplication
import { describe, it, expect } from "vitest";
import { lintHyperframeHtml } from "../hyperframeLinter.js";

Expand Down
101 changes: 101 additions & 0 deletions packages/core/src/lint/rules/gsap.test.ts
Original file line number Diff line number Diff line change
@@ -1,3 +1,4 @@
// fallow-ignore-file code-duplication
import { describe, it, expect } from "vitest";
import { lintHyperframeHtml } from "../hyperframeLinter.js";

Expand Down Expand Up @@ -937,4 +938,104 @@ describe("GSAP rules", () => {
const finding = result.findings.find((f) => f.code === "gsap_timeline_not_registered");
expect(finding).toBeUndefined();
});

// gsap_studio_edit_blocked
it("warns when script registers timeline AND has GSAP tweens targeting #id selectors", async () => {
const html = `
<html><body>
<div data-composition-id="c1" data-width="1920" data-height="1080">
<div id="headline" style="position:absolute;left:120px;top:200px;">Hello</div>
</div>
<script src="https://cdn.jsdelivr.net/npm/gsap@3.14.2/dist/gsap.min.js"></script>
<script>
window.__timelines = window.__timelines || {};
const tl = gsap.timeline({ paused: true });
tl.set("#headline", { opacity: 0 });
tl.to("#headline", { opacity: 1, duration: 0.5 }, 0);
window.__timelines["c1"] = tl;
</script>
</body></html>`;
const result = await lintHyperframeHtml(html);
const finding = result.findings.find((f) => f.code === "gsap_studio_edit_blocked");
expect(finding).toBeDefined();
expect(finding?.severity).toBe("warning");
expect(finding?.message).toContain('"#headline"');
});

it("warns when script registers timeline AND has GSAP tweens targeting .class selectors", async () => {
const html = `
<html><body>
<div data-composition-id="c1" data-width="1920" data-height="1080">
<div class="box" style="position:absolute;left:120px;top:200px;"></div>
</div>
<script src="https://cdn.jsdelivr.net/npm/gsap@3.14.2/dist/gsap.min.js"></script>
<script>
window.__timelines = window.__timelines || {};
const tl = gsap.timeline({ paused: true });
tl.from(".box", { y: 80, opacity: 0, duration: 0.4 }, 0);
window.__timelines["c1"] = tl;
</script>
</body></html>`;
const result = await lintHyperframeHtml(html);
const finding = result.findings.find((f) => f.code === "gsap_studio_edit_blocked");
expect(finding).toBeDefined();
expect(finding?.message).toContain('".box"');
});

it("does NOT warn when timeline is registered but no GSAP element selectors are called", async () => {
const html = `
<html><body>
<div data-composition-id="c1" data-width="1920" data-height="1080"></div>
<script src="https://cdn.jsdelivr.net/npm/gsap@3.14.2/dist/gsap.min.js"></script>
<script>
window.__timelines = window.__timelines || {};
const tl = gsap.timeline({ paused: true });
window.__timelines["c1"] = tl;
</script>
</body></html>`;
const result = await lintHyperframeHtml(html);
const finding = result.findings.find((f) => f.code === "gsap_studio_edit_blocked");
expect(finding).toBeUndefined();
});

it("does NOT warn when script has GSAP calls but does not register on window.__timelines", async () => {
const html = `
<html><body>
<div data-composition-id="c1" data-width="1920" data-height="1080">
<div id="box"></div>
</div>
<script src="https://cdn.jsdelivr.net/npm/gsap@3.14.2/dist/gsap.min.js"></script>
<script>
window.__timelines = window.__timelines || {};
const tl = gsap.timeline({ paused: true });
tl.to("#box", { x: 100, duration: 1 }, 0);
</script>
</body></html>`;
const result = await lintHyperframeHtml(html);
const finding = result.findings.find((f) => f.code === "gsap_studio_edit_blocked");
expect(finding).toBeUndefined();
});

it("lists all unique targeted selectors in the warning message", async () => {
const html = `
<html><body>
<div data-composition-id="c1" data-width="1920" data-height="1080">
<div id="title"></div>
<div id="sub"></div>
</div>
<script src="https://cdn.jsdelivr.net/npm/gsap@3.14.2/dist/gsap.min.js"></script>
<script>
window.__timelines = window.__timelines || {};
const tl = gsap.timeline({ paused: true });
tl.from("#title", { opacity: 0, duration: 0.3 }, 0);
tl.from("#sub", { opacity: 0, duration: 0.3 }, 0.2);
window.__timelines["c1"] = tl;
</script>
</body></html>`;
const result = await lintHyperframeHtml(html);
const finding = result.findings.find((f) => f.code === "gsap_studio_edit_blocked");
expect(finding).toBeDefined();
expect(finding?.message).toContain('"#title"');
expect(finding?.message).toContain('"#sub"');
});
});
50 changes: 49 additions & 1 deletion packages/core/src/lint/rules/gsap.ts
Original file line number Diff line number Diff line change
Expand Up @@ -22,7 +22,12 @@ async function loadParseGsapScript(): Promise<(script: string) => LintParsedGsap
import type { LintContext } from "../context";
import type { HyperframeLintFinding, LintRule } from "../types";
import type { OpenTag } from "../utils";
import { readAttr, truncateSnippet, WINDOW_TIMELINE_ASSIGN_PATTERN } from "../utils";
import {
readAttr,
truncateSnippet,
WINDOW_TIMELINE_ASSIGN_PATTERN,
TIMELINE_REGISTRY_ASSIGN_PATTERN,
} from "../utils";

// ── GSAP-specific types ────────────────────────────────────────────────────

Expand All @@ -47,6 +52,7 @@ const SCENE_BOUNDARY_EPSILON_SECONDS = 0.05;

// ── GSAP parsing utilities ─────────────────────────────────────────────────

// fallow-ignore-next-line complexity
function stripJsComments(source: string): string {
let out = "";
let i = 0;
Expand Down Expand Up @@ -161,6 +167,7 @@ function synthesizeWindowRaw(
// parser already resolves variable targets (`tl.to(kicker, …)`) to selectors
// and excludes non-DOM object-target anchors (`tl.to({ _: 0 }, …)`), so there's
// no fragile positional pairing between a regex walk and the parsed list.
// fallow-ignore-next-line complexity
async function extractGsapWindows(script: string): Promise<GsapWindow[]> {
if (!/gsap\.timeline/.test(script)) return [];
const parseGsapScript = await loadParseGsapScript();
Expand Down Expand Up @@ -334,6 +341,7 @@ function getSingleClassSelector(selector: string): string | null {
return match?.groups?.name || null;
}

// fallow-ignore-next-line complexity
function cssTransformToGsapProps(cssTransform: string): string | null {
const parts: string[] = [];

Expand Down Expand Up @@ -374,8 +382,10 @@ function cssTransformToGsapProps(cssTransform: string): string | null {

// ── GSAP rules ─────────────────────────────────────────────────────────────

// fallow-ignore-next-line complexity
export const gsapRules: LintRule<LintContext>[] = [
// overlapping_gsap_tweens + gsap_animates_clip_element + unscoped_gsap_selector
// fallow-ignore-next-line complexity
async ({ source, tags, scripts, rootCompositionId }) => {
const findings: HyperframeLintFinding[] = [];

Expand Down Expand Up @@ -505,6 +515,7 @@ export const gsapRules: LintRule<LintContext>[] = [
},

// gsap_css_transform_conflict
// fallow-ignore-next-line complexity
async ({ styles, scripts, tags }) => {
const findings: HyperframeLintFinding[] = [];
const cssTranslateSelectors = new Map<string, string>();
Expand Down Expand Up @@ -642,6 +653,7 @@ export const gsapRules: LintRule<LintContext>[] = [
},

// audio_reactive_single_tween_per_group
// fallow-ignore-next-line complexity
({ scripts, styles }) => {
const findings: HyperframeLintFinding[] = [];
const isCaptionFile = styles.some((s) => /\.caption[-_]?(?:group|word)/i.test(s.content));
Expand Down Expand Up @@ -813,6 +825,7 @@ export const gsapRules: LintRule<LintContext>[] = [
},

// gsap_from_opacity_noop — CSS opacity:0 + gsap.from({opacity:0}) = invisible forever
// fallow-ignore-next-line complexity
async ({ styles, scripts, tags }) => {
const findings: HyperframeLintFinding[] = [];
const cssOpacityZeroSelectors = new Set<string>();
Expand Down Expand Up @@ -896,4 +909,39 @@ export const gsapRules: LintRule<LintContext>[] = [
}
return findings;
},

// gsap_studio_edit_blocked
// When a script both registers a timeline on window.__timelines AND contains
// GSAP mutation calls targeting element selectors, Studio's isElementGsapTargeted
// check returns true for those elements and silently skips saving drag/resize
// position changes back to source HTML.
({ scripts }) => {
const findings: HyperframeLintFinding[] = [];
const GSAP_MUTATION_SELECTOR_RE = /\.\s*(?:set|to|from|fromTo)\s*\(\s*["']([#.][^"']+)["']/g;

for (const script of scripts) {
const content = stripJsComments(script.content);
if (!TIMELINE_REGISTRY_ASSIGN_PATTERN.test(content)) continue;

const targets = new Set<string>();
let match: RegExpExecArray | null;
const re = new RegExp(GSAP_MUTATION_SELECTOR_RE.source, "g");
while ((match = re.exec(content)) !== null) {
if (match[1]) targets.add(match[1]);
}
if (targets.size === 0) continue;

const selList = [...targets].map((s) => `"${s}"`).join(", ");
findings.push({
code: "gsap_studio_edit_blocked",
severity: "warning",
message: `GSAP tweens target ${selList} in a registered timeline. Studio cannot save drag/resize edits to these elements — the runtime skips write-back for any element that appears in a registered window.__timelines timeline.`,
fixHint:
"The hyperframes runtime registers timelines automatically. Do not add a manual window.__timelines script unless GSAP intentionally controls element positions. " +
"For initial visibility states, use CSS (e.g. opacity:0) instead of gsap.set(). " +
"If GSAP must own these elements' positions, avoid drag-editing them in Studio.",
});
}
return findings;
},
];
Loading
Loading