diff --git a/packages/core/src/lint/rules/adapters.test.ts b/packages/core/src/lint/rules/adapters.test.ts index 938efc784..d28e49abc 100644 --- a/packages/core/src/lint/rules/adapters.test.ts +++ b/packages/core/src/lint/rules/adapters.test.ts @@ -1,3 +1,4 @@ +// fallow-ignore-file code-duplication import { describe, it, expect } from "vitest"; import { lintHyperframeHtml } from "../hyperframeLinter.js"; diff --git a/packages/core/src/lint/rules/gsap.test.ts b/packages/core/src/lint/rules/gsap.test.ts index 6ef3c57ea..7b89cc723 100644 --- a/packages/core/src/lint/rules/gsap.test.ts +++ b/packages/core/src/lint/rules/gsap.test.ts @@ -1,3 +1,4 @@ +// fallow-ignore-file code-duplication import { describe, it, expect } from "vitest"; import { lintHyperframeHtml } from "../hyperframeLinter.js"; @@ -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 = ` + +
+
Hello
+
+ + +`; + 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 = ` + +
+
+
+ + +`; + 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 = ` + +
+ + +`; + 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 = ` + +
+
+
+ + +`; + 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 = ` + +
+
+
+
+ + +`; + 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"'); + }); }); diff --git a/packages/core/src/lint/rules/gsap.ts b/packages/core/src/lint/rules/gsap.ts index fb5d7b3f8..fe76ce280 100644 --- a/packages/core/src/lint/rules/gsap.ts +++ b/packages/core/src/lint/rules/gsap.ts @@ -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 ──────────────────────────────────────────────────── @@ -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; @@ -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 { if (!/gsap\.timeline/.test(script)) return []; const parseGsapScript = await loadParseGsapScript(); @@ -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[] = []; @@ -374,8 +382,10 @@ function cssTransformToGsapProps(cssTransform: string): string | null { // ── GSAP rules ───────────────────────────────────────────────────────────── +// fallow-ignore-next-line complexity export const gsapRules: LintRule[] = [ // overlapping_gsap_tweens + gsap_animates_clip_element + unscoped_gsap_selector + // fallow-ignore-next-line complexity async ({ source, tags, scripts, rootCompositionId }) => { const findings: HyperframeLintFinding[] = []; @@ -505,6 +515,7 @@ export const gsapRules: LintRule[] = [ }, // gsap_css_transform_conflict + // fallow-ignore-next-line complexity async ({ styles, scripts, tags }) => { const findings: HyperframeLintFinding[] = []; const cssTranslateSelectors = new Map(); @@ -642,6 +653,7 @@ export const gsapRules: LintRule[] = [ }, // 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)); @@ -813,6 +825,7 @@ export const gsapRules: LintRule[] = [ }, // 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(); @@ -896,4 +909,39 @@ export const gsapRules: LintRule[] = [ } 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(); + 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; + }, ];