mirror of
https://github.com/heygen-com/hyperframes.git
synced 2026-09-03 04:38:33 +00:00
fix(lint): catch CSS↔GSAP transform conflicts in scoped selectors and frame sub-compositions
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) <noreply@anthropic.com>
This commit is contained in:
co-authored by
Claude Opus 4.8
parent
d596379e52
commit
9ccae86372
@@ -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 = `<template data-composition-id="04-mechanism">
|
||||
<div id="m04-root" data-width="1920" data-height="1080">
|
||||
<div class="m04-label">edit op</div>
|
||||
</div>
|
||||
<style> .m04-label { position: absolute; left: 960px; transform: translateX(-50%); } </style>
|
||||
<script src="https://cdn.jsdelivr.net/npm/gsap@3/dist/gsap.min.js"></script>
|
||||
<script>
|
||||
window.__timelines = window.__timelines || {};
|
||||
const tl = gsap.timeline({ paused: true });
|
||||
gsap.set("#m04-root .m04-label", { xPercent: -50 });
|
||||
tl.to(".m04-label", { y: 0, opacity: 1, duration: 0.4 }, 0.5);
|
||||
window.__timelines["04-mechanism"] = tl;
|
||||
</script>
|
||||
</template>`;
|
||||
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(),
|
||||
|
||||
@@ -200,7 +200,19 @@ export async function lintProject(project: ProjectDir): Promise<ProjectLintResul
|
||||
const allHtmlSources: HtmlSource[] = [{ html: rootHtml }];
|
||||
const compositionsDir = resolve(project.dir, "compositions");
|
||||
if (existsSync(compositionsDir)) {
|
||||
const files = readdirSync(compositionsDir).filter((f) => 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");
|
||||
|
||||
@@ -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 = `
|
||||
<html><body>
|
||||
<div id="root" data-composition-id="c1" data-width="1920" data-height="1080">
|
||||
<div class="lab">Label</div>
|
||||
</div>
|
||||
<style>
|
||||
.lab { transform: translateX(-50%); }
|
||||
</style>
|
||||
<script>
|
||||
window.__timelines = window.__timelines || {};
|
||||
const tl = gsap.timeline({ paused: true });
|
||||
tl.to("#root .lab", { x: 40, opacity: 1, duration: 0.4 }, 0.5);
|
||||
window.__timelines["c1"] = tl;
|
||||
</script>
|
||||
</body></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 = `
|
||||
<html><body>
|
||||
<div id="root" data-composition-id="c1" data-width="1920" data-height="1080">
|
||||
<div class="lab">A</div><div class="sub">B</div>
|
||||
</div>
|
||||
<style>
|
||||
.lab { transform: translateX(-50%); }
|
||||
.sub { transform: translateX(-50%); }
|
||||
</style>
|
||||
<script>
|
||||
window.__timelines = window.__timelines || {};
|
||||
const tl = gsap.timeline({ paused: true });
|
||||
gsap.set("#root .lab, #root .sub", { xPercent: -50 });
|
||||
tl.to(".lab", { y: 0, opacity: 1, duration: 0.4 }, 0.5);
|
||||
window.__timelines["c1"] = tl;
|
||||
</script>
|
||||
</body></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 = `
|
||||
<html><body>
|
||||
<div id="root" data-composition-id="c1" data-width="1920" data-height="1080">
|
||||
<div class="lab">Label</div>
|
||||
</div>
|
||||
<style>
|
||||
.lab { opacity: 0; }
|
||||
</style>
|
||||
<script>
|
||||
window.__timelines = window.__timelines || {};
|
||||
const tl = gsap.timeline({ paused: true });
|
||||
tl.to("#root .lab", { x: 40, opacity: 1, duration: 0.4 }, 0.5);
|
||||
window.__timelines["c1"] = tl;
|
||||
</script>
|
||||
</body></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 = `
|
||||
<html><body>
|
||||
|
||||
@@ -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<string> {
|
||||
const tokens = new Set<string>();
|
||||
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, string>): 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<LintContext>[] = [
|
||||
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<string>; raw: string };
|
||||
const conflicts = new Map<string, Conflict>();
|
||||
|
||||
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<string>(),
|
||||
raw: win.raw,
|
||||
raw: call.raw,
|
||||
};
|
||||
for (const p of [...translateProps, ...scaleProps]) existing.props.add(p);
|
||||
conflicts.set(sel, existing);
|
||||
|
||||
Reference in New Issue
Block a user