diff --git a/packages/cli/src/utils/lintProject.test.ts b/packages/cli/src/utils/lintProject.test.ts
index 755450aaa..e31601aac 100644
--- a/packages/cli/src/utils/lintProject.test.ts
+++ b/packages/cli/src/utils/lintProject.test.ts
@@ -82,6 +82,47 @@ describe("lintProject", () => {
expect(mediaFinding).toBeDefined();
});
+ it("recurses into compositions/frames/ and flags a CSS↔GSAP transform conflict there", async () => {
+ // End-to-end guard: a per-frame composition under compositions/frames/ that
+ // seats centering via a standalone gsap.set on a grouped #root-scoped selector
+ // against a CSS class transform — the exact shape that shipped off-centre.
+ // Both the recursive discovery and the strengthened rule must fire.
+ const dir = tmpProject("lint-frames");
+ dirs.push(dir);
+ writeFileSync(join(dir, "index.html"), validHtml());
+ const framesDir = join(dir, "compositions", "frames");
+ mkdirSync(framesDir, { recursive: true });
+ const frameHtml = `
+
+
+
+
+`;
+ writeFileSync(join(framesDir, "04-mechanism.html"), frameHtml);
+
+ const project: ProjectDir = {
+ dir,
+ name: "test-project",
+ indexPath: join(dir, "index.html"),
+ };
+ const { results } = await lintProject(project);
+
+ const frameResult = results.find((r) => r.file === "compositions/frames/04-mechanism.html");
+ expect(frameResult).toBeDefined();
+ const conflict = frameResult?.result.findings.find(
+ (f) => f.code === "gsap_css_transform_conflict",
+ );
+ expect(conflict).toBeDefined();
+ });
+
it("lints sub-compositions in compositions/ directory", async () => {
const project = makeProject(validHtml(), {
"captions.html": htmlWithMissingMediaId(),
diff --git a/packages/cli/src/utils/lintProject.ts b/packages/cli/src/utils/lintProject.ts
index 66b1e16cf..2fe61902e 100644
--- a/packages/cli/src/utils/lintProject.ts
+++ b/packages/cli/src/utils/lintProject.ts
@@ -200,7 +200,19 @@ export async function lintProject(project: ProjectDir): Promise f.endsWith(".html"));
+ // Recurse: per-frame compositions live in nested dirs (e.g. compositions/frames/*.html).
+ // A non-recursive readdir silently skipped them, so sub-composition rules never ran on
+ // the frames that make up the video. Walk the whole tree; keep posix-style src paths.
+ const collectHtmlFiles = (dir: string, rel: string): string[] => {
+ const out: string[] = [];
+ for (const entry of readdirSync(dir, { withFileTypes: true })) {
+ const relPath = rel ? `${rel}/${entry.name}` : entry.name;
+ if (entry.isDirectory()) out.push(...collectHtmlFiles(join(dir, entry.name), relPath));
+ else if (entry.isFile() && entry.name.endsWith(".html")) out.push(relPath);
+ }
+ return out;
+ };
+ const files = collectHtmlFiles(compositionsDir, "").sort();
for (const file of files) {
const filePath = join(compositionsDir, file);
const html = readFileSync(filePath, "utf-8");
diff --git a/packages/core/src/lint/rules/gsap.test.ts b/packages/core/src/lint/rules/gsap.test.ts
index 7898b6643..98cf75ba1 100644
--- a/packages/core/src/lint/rules/gsap.test.ts
+++ b/packages/core/src/lint/rules/gsap.test.ts
@@ -519,6 +519,77 @@ describe("GSAP rules", () => {
expect(conflicts.length).toBeGreaterThanOrEqual(1);
});
+ it("detects conflict via a SCOPED descendant selector (tl.to)", async () => {
+ const html = `
+
+
+
+
+`;
+ const result = await lintHyperframeHtml(html);
+ const finding = result.findings.find((f) => f.code === "gsap_css_transform_conflict");
+ expect(finding).toBeDefined();
+ expect(finding?.selector).toBe("#root .lab");
+ });
+
+ it("detects conflict via a standalone gsap.set with a GROUPED scoped selector", async () => {
+ // The exact shape that slipped through: centering seated with a standalone
+ // gsap.set on a grouped, #root-scoped selector, against a CSS class transform.
+ const html = `
+
+
+
+
+`;
+ const result = await lintHyperframeHtml(html);
+ const finding = result.findings.find(
+ (f) => f.code === "gsap_css_transform_conflict" && f.selector === "#root .lab, #root .sub",
+ );
+ expect(finding).toBeDefined();
+ expect(finding?.severity).toBe("error");
+ });
+
+ it("does NOT false-positive when a scoped selector targets a class WITHOUT a CSS transform", async () => {
+ const html = `
+
+
+
+
+`;
+ const result = await lintHyperframeHtml(html);
+ const conflict = result.findings.find((f) => f.code === "gsap_css_transform_conflict");
+ expect(conflict).toBeUndefined();
+ });
+
it("reports error when GSAP is used without a GSAP script tag", async () => {
const html = `
diff --git a/packages/core/src/lint/rules/gsap.ts b/packages/core/src/lint/rules/gsap.ts
index 1a04efd9a..2d311dbfb 100644
--- a/packages/core/src/lint/rules/gsap.ts
+++ b/packages/core/src/lint/rules/gsap.ts
@@ -321,6 +321,78 @@ function cssTransformToGsapProps(cssTransform: string): string | null {
return parts.length > 0 ? parts.join(", ") : null;
}
+// ── CSS-transform ↔ GSAP-transform conflict matching ─────────────────────────
+
+// Transform components that COMBINE with a CSS translate/scale on the same
+// element. GSAP bakes the element's existing CSS transform in when it seeks, so
+// these stack rather than override in the capture path (e.g. CSS translateX(-50%)
+// + xPercent:-50 renders as -100% — off-centre). `rotation` is excluded: it maps
+// to CSS rotate(), which this rule treats separately (no false positive on spin).
+const CONFLICTING_TRANSLATE_PROPS = ["x", "y", "xPercent", "yPercent"];
+const CONFLICTING_SCALE_PROPS = ["scale", "scaleX", "scaleY"];
+
+type GsapTransformCall = {
+ method: string;
+ selector: string;
+ properties: string[];
+ raw: string;
+};
+
+// Decompose a (possibly grouped / descendant / compound) GSAP target selector
+// into the simple `#id` / `.class` tokens of the elements it actually targets —
+// the RIGHTMOST compound of each comma group is the targeted element. This lets a
+// CSS rule keyed by a simple selector (`.m04-label`) match a scoped GSAP selector
+// (`"#root .m04-label, #root .m04-sub"`), which the prior exact-string lookup
+// missed — so every scoped/grouped selector slipped past the rule entirely.
+function targetedSelectorTokens(selector: string): Set {
+ const tokens = new Set();
+ for (const group of selector.split(",")) {
+ const compounds = group
+ .trim()
+ .split(/[\s>+~]+/)
+ .filter(Boolean);
+ const last = compounds[compounds.length - 1];
+ if (!last) continue;
+ const simple = last.match(/[#.][A-Za-z0-9_-]+/g);
+ if (simple) for (const token of simple) tokens.add(token);
+ }
+ return tokens;
+}
+
+// Find a CSS transform conflicting with a GSAP target selector: exact-string
+// match first (fast path + back-compat with the original behaviour), then a
+// token match so scoped/grouped/descendant selectors resolve to their class/id.
+function matchCssTransform(gsapSelector: string, cssMap: Map): string | undefined {
+ if (cssMap.size === 0) return undefined;
+ const direct = cssMap.get(gsapSelector);
+ if (direct) return direct;
+ const tokens = targetedSelectorTokens(gsapSelector);
+ for (const [cssSelector, value] of cssMap) {
+ if (tokens.has(cssSelector)) return value;
+ }
+ return undefined;
+}
+
+// Scan for STANDALONE `gsap.set/to/from/fromTo("selector", { ...props })` calls.
+// The acorn timeline parser only captures calls rooted on the timeline var
+// (`tl.to`, `tl.set`, …); a top-level `gsap.set("#root .label", { xPercent: -50 })`
+// — a common way to seat shared base transforms before the timeline runs — is
+// invisible to it, so the conflict rule never saw it. Variable selectors
+// (`gsap.set(kicker, …)`) can't be resolved statically and are skipped.
+function extractStandaloneGsapTransformCalls(script: string): GsapTransformCall[] {
+ const calls: GsapTransformCall[] = [];
+ const pattern = /gsap\.(set|to|from|fromTo)\s*\(\s*(["'])([^"']+)\2\s*,\s*\{([^{}]*)\}/g;
+ let match: RegExpExecArray | null;
+ while ((match = pattern.exec(script)) !== null) {
+ const method = match[1] ?? "set";
+ const selector = match[3] ?? "";
+ const propsBody = match[4] ?? "";
+ const properties = [...propsBody.matchAll(/([A-Za-z_$][\w$]*)\s*:/g)].map((m) => m[1] ?? "");
+ calls.push({ method, selector, properties, raw: truncateSnippet(match[0]) ?? match[0] });
+ }
+ return calls;
+}
+
// ── GSAP rules ─────────────────────────────────────────────────────────────
// fallow-ignore-next-line complexity
@@ -505,27 +577,40 @@ export const gsapRules: LintRule[] = [
if (!/gsap\.timeline/.test(script.content)) continue;
const windows = await cachedExtractGsapWindows(script.content);
+ // Two sources of transform-setting calls: timeline-rooted tweens (from the
+ // acorn parser) and standalone gsap.* calls (regex — the parser ignores
+ // these). Normalize both into one shape and run the same conflict check.
+ const calls: GsapTransformCall[] = [
+ ...windows.map((win) => ({
+ method: win.method,
+ selector: win.targetSelector,
+ properties: win.properties,
+ raw: win.raw,
+ })),
+ ...extractStandaloneGsapTransformCalls(stripJsComments(script.content)),
+ ];
+
type Conflict = { cssTransform: string; props: Set; raw: string };
const conflicts = new Map();
- for (const win of windows) {
+ for (const call of calls) {
// from() and fromTo() both supply explicit start values so GSAP owns
// the full transform from t=0, making the CSS conflict moot
- if (win.method === "fromTo" || win.method === "from") continue;
- const sel = win.targetSelector;
- const cssKey = sel.startsWith("#") || sel.startsWith(".") ? sel : `#${sel}`;
- const translateProps = win.properties.filter((p) =>
- ["x", "y", "xPercent", "yPercent"].includes(p),
+ if (call.method === "fromTo" || call.method === "from") continue;
+ const sel = call.selector;
+ const translateProps = call.properties.filter((p) =>
+ CONFLICTING_TRANSLATE_PROPS.includes(p),
);
- const scaleProps = win.properties.filter((p) => p === "scale");
+ const scaleProps = call.properties.filter((p) => CONFLICTING_SCALE_PROPS.includes(p));
const cssFromTranslate =
- translateProps.length > 0 ? cssTranslateSelectors.get(cssKey) : undefined;
- const cssFromScale = scaleProps.length > 0 ? cssScaleSelectors.get(cssKey) : undefined;
+ translateProps.length > 0 ? matchCssTransform(sel, cssTranslateSelectors) : undefined;
+ const cssFromScale =
+ scaleProps.length > 0 ? matchCssTransform(sel, cssScaleSelectors) : undefined;
if (!cssFromTranslate && !cssFromScale) continue;
const existing = conflicts.get(sel) ?? {
cssTransform: [cssFromTranslate, cssFromScale].filter(Boolean).join(" "),
props: new Set(),
- raw: win.raw,
+ raw: call.raw,
};
for (const p of [...translateProps, ...scaleProps]) existing.props.add(p);
conflicts.set(sel, existing);