From 9ccae8637291262b8a2e1a1779e4ee7ad186f675 Mon Sep 17 00:00:00 2001 From: Miao Yang Date: Tue, 23 Jun 2026 15:53:36 +0800 Subject: [PATCH] =?UTF-8?q?fix(lint):=20catch=20CSS=E2=86=94GSAP=20transfo?= =?UTF-8?q?rm=20conflicts=20in=20scoped=20selectors=20and=20frame=20sub-co?= =?UTF-8?q?mpositions?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit gsap_css_transform_conflict existed but missed the most common real-world shape (a label centered with CSS translateX(-50%) plus a GSAP xPercent that stacks to -100% in the capture path), for three independent reasons: - selector matching was exact-string, so a scoped/grouped GSAP selector ("#root .label, #root .sub") never matched a CSS class rule (.label) - the acorn parser only captures timeline-rooted calls (tl.to/tl.set), so a standalone gsap.set("#root .label", { xPercent: -50 }) was invisible to it - lintProject read compositions/ non-recursively, so per-frame compositions in compositions/frames/*.html were never linted at all Fix: token-decompose grouped/descendant/compound selectors and match by id/class against CSS transform rules; additionally scan standalone gsap.* transform calls; and recurse into compositions/ subdirectories so frame sub-compositions are linted. Adds unit tests (grouped gsap.set repro, descendant tl.to, negative case) and an end-to-end lintProject test that writes compositions/frames/04-*.html and asserts the conflict is reported there. Co-Authored-By: Claude Opus 4.8 (1M context) --- packages/cli/src/utils/lintProject.test.ts | 41 ++++++++ packages/cli/src/utils/lintProject.ts | 14 ++- packages/core/src/lint/rules/gsap.test.ts | 71 ++++++++++++++ packages/core/src/lint/rules/gsap.ts | 105 +++++++++++++++++++-- 4 files changed, 220 insertions(+), 11 deletions(-) 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 = ` + +
+
Label
+
+ + +`; + 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 = ` + +
+
A
B
+
+ + +`; + 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 = ` + +
+
Label
+
+ + +`; + 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);