From e2d804030300161a359c2e0f6dba89f45922852a Mon Sep 17 00:00:00 2001 From: Miguel Angel Simon Sierra Date: Tue, 30 Jun 2026 21:28:15 -0700 Subject: [PATCH] feat(lint): warn on non-seek-safe GSAP className animation Add gsap_classname_not_seek_safe (warning) to the GSAP lint rules. It fires on any GSAP tween method (set/to/from/fromTo) whose vars include a className property. GSAP's className special property is stateful and assumes sequential playback. HyperFrames renders by seeking a paused timeline to arbitrary frame times, so className add/remove (especially the relative +=class / -=class syntax) does not apply reliably under seek: it silently degrades and leaves the class unapplied, with no lint/validate/runtime error. The fix hint points to tl.call(() => el.classList.add/remove(...)) at the desired time, or animating the concrete CSS properties directly. Severity is warning, not error: existing compositions use className and it works in live sequential preview, so this guides without breaking builds. The acorn GSAP parser already surfaces className in a tween's properties, so the rule reads it via the existing extraction path with no parser change. Tests cover the relative +=class set, a plain className to(), a normal transform/opacity tween that must not fire, and that the finding is a warning that never counts as an error. The new code is also added to the GSAP rule catalog in the frame-worker skill sub-agents. --- packages/lint/src/rules/gsap.test.ts | 80 +++++++++++++++++++ packages/lint/src/rules/gsap.ts | 33 ++++++++ skills-manifest.json | 6 +- .../sub-agents/frame-worker.md | 1 + skills/pr-to-video/sub-agents/frame-worker.md | 1 + .../sub-agents/frame-worker.md | 1 + 6 files changed, 119 insertions(+), 3 deletions(-) diff --git a/packages/lint/src/rules/gsap.test.ts b/packages/lint/src/rules/gsap.test.ts index d60992b2d..00c9ba3e0 100644 --- a/packages/lint/src/rules/gsap.test.ts +++ b/packages/lint/src/rules/gsap.test.ts @@ -1372,4 +1372,84 @@ describe("GSAP rules", () => { const finding = result.findings.find((f) => f.code === "scene_layer_missing_visibility_kill"); expect(finding).toBeUndefined(); }); + + it("gsap_classname_not_seek_safe: fires on tl.set with relative +=class syntax", async () => { + const html = ` + +
+
+
+ +`; + const result = await lintHyperframeHtml(html); + const finding = result.findings.find((f) => f.code === "gsap_classname_not_seek_safe"); + expect(finding).toBeDefined(); + expect(finding?.severity).toBe("warning"); + }); + + it("gsap_classname_not_seek_safe: fires on tl.to with a className var", async () => { + const html = ` + +
+
+
+ +`; + const result = await lintHyperframeHtml(html); + const finding = result.findings.find((f) => f.code === "gsap_classname_not_seek_safe"); + expect(finding).toBeDefined(); + expect(finding?.severity).toBe("warning"); + }); + + it("gsap_classname_not_seek_safe: does NOT fire on a plain transform/opacity tween", async () => { + const html = ` + +
+
+
+ +`; + const result = await lintHyperframeHtml(html); + const finding = result.findings.find((f) => f.code === "gsap_classname_not_seek_safe"); + expect(finding).toBeUndefined(); + }); + + it("gsap_classname_not_seek_safe: is a warning, does not raise errorCount", async () => { + const html = ` + +
+
+
+ +`; + const result = await lintHyperframeHtml(html); + const finding = result.findings.find((f) => f.code === "gsap_classname_not_seek_safe"); + expect(finding?.severity).toBe("warning"); + // The rule contributes a warning, never an error: no gsap_classname_not_seek_safe + // finding should ever count toward errorCount. + const asError = result.findings.some( + (f) => f.code === "gsap_classname_not_seek_safe" && f.severity === "error", + ); + expect(asError).toBe(false); + }); }); diff --git a/packages/lint/src/rules/gsap.ts b/packages/lint/src/rules/gsap.ts index 03fd0e621..2bc5f42fd 100644 --- a/packages/lint/src/rules/gsap.ts +++ b/packages/lint/src/rules/gsap.ts @@ -1151,4 +1151,37 @@ export const gsapRules: LintRule[] = [ } return findings; }, + + // gsap_classname_not_seek_safe: GSAP's `className` special property is stateful + // and assumes sequential playback. HyperFrames renders by seeking a paused timeline + // to arbitrary frame times, so className add/remove (especially the relative + // `+=class` / `-=class` syntax) does not apply reliably under seek: it silently + // degrades, leaving the class unapplied (e.g. an element rendering as unstyled + // top-left text) with no lint/validate/runtime error. Warning, not error: existing + // comps use it and it "works" in live sequential preview. + async ({ scripts }) => { + const findings: HyperframeLintFinding[] = []; + for (const script of scripts) { + const windows = await cachedExtractGsapWindows(script.content); + for (const win of windows) { + if (!win.properties.includes("className")) continue; + findings.push({ + code: "gsap_classname_not_seek_safe", + severity: "warning", + message: + `GSAP tween sets \`className\` on "${win.targetSelector}". GSAP's className animation ` + + "is not seek-safe in HyperFrames' frame-by-frame render: it assumes sequential playback " + + "and silently degrades under seek, leaving the class unapplied (often rendering as " + + "unstyled top-left text) with no lint/validate/runtime error. This is especially fragile " + + "with the relative `+=class` / `-=class` add/remove syntax.", + selector: win.targetSelector, + fixHint: + `Toggle the class at the desired time with \`tl.call(() => el.classList.add("your-class"))\` ` + + "(or classList.remove), or animate the concrete CSS properties directly instead of toggling a class.", + snippet: truncateSnippet(win.raw), + }); + } + } + return findings; + }, ]; diff --git a/skills-manifest.json b/skills-manifest.json index cf3d5ba60..4334d4640 100644 --- a/skills-manifest.json +++ b/skills-manifest.json @@ -6,7 +6,7 @@ "files": 144 }, "faceless-explainer": { - "hash": "edbe47dd738d14d8", + "hash": "6b2bc97b03c20586", "files": 17 }, "general-video": { @@ -54,11 +54,11 @@ "files": 132 }, "pr-to-video": { - "hash": "ef4a3aa5a943aeec", + "hash": "dd2ea8d0267b215a", "files": 21 }, "product-launch-video": { - "hash": "b7bee220096f2ae2", + "hash": "c51ad031ed402ac6", "files": 18 }, "remotion-to-hyperframes": { diff --git a/skills/faceless-explainer/sub-agents/frame-worker.md b/skills/faceless-explainer/sub-agents/frame-worker.md index 1528410b6..f5c21cdbe 100644 --- a/skills/faceless-explainer/sub-agents/frame-worker.md +++ b/skills/faceless-explainer/sub-agents/frame-worker.md @@ -67,6 +67,7 @@ You **can't** meaningfully run `hyperframes lint` / `validate` / `inspect` here: - `timeline_not_paused` / `timeline_not_registered` — one paused timeline, registered at `window.__timelines[""]`. - `css_transition_used` + repeat / yoyo / non-deterministic logic — none present (the renderer seeks frame-by-frame). - `gsap_css_transform_conflict` — never put a CSS `transform` (e.g. `translateY(-50%)` centering) on an element you then GSAP-animate a transform prop on (`x` / `y` / `scale` / `rotation`): GSAP overwrites the whole `transform` and silently drops the CSS centering (the element jumps). Center with `margin` / `inset` (or `top`/`left` + offset), fold the offset into the tween via `xPercent` / `yPercent`, or use `fromTo` (the rule exempts it). +- `gsap_classname_not_seek_safe` — never animate GSAP's `className` special property (`tl.set(el, { className: "+=locked" })` / `tl.to(el, { className: "active" })`, especially the relative `+=` / `-=` syntax): it assumes sequential playback and does not apply reliably under the frame-by-frame seek, silently degrading and leaving the class unapplied (often rendering as unstyled top-left text) with no lint/validate/runtime error. Toggle the class at the desired time with `tl.call(() => el.classList.add("your-class"))` (or `classList.remove`), or animate the concrete CSS properties directly instead of a class. - **Hero visibility** — the main subject is visible by `t <= 0.5s`; entrance tweens use `fromTo` instead of CSS-hidden starting states. - `exit_animation_on_non_final_scene` — no exit tween unless you are the final frame. - **No front-loading (not a slide)** — the shot's pieces reveal on their `voiceover` cues across the duration, not all fired at `t=0`; a non-still frame keeps content arriving rather than holding a full canvas from ~25%. diff --git a/skills/pr-to-video/sub-agents/frame-worker.md b/skills/pr-to-video/sub-agents/frame-worker.md index ea674c857..b95ea2b6c 100644 --- a/skills/pr-to-video/sub-agents/frame-worker.md +++ b/skills/pr-to-video/sub-agents/frame-worker.md @@ -86,6 +86,7 @@ You **can't** meaningfully run `hyperframes lint` / `validate` / `inspect` here: - `timeline_not_paused` / `timeline_not_registered` — one paused timeline, registered at `window.__timelines[""]`. - `css_transition_used` + repeat / yoyo / non-deterministic logic — none present (the renderer seeks frame-by-frame). - `gsap_css_transform_conflict` — never put a CSS `transform` (e.g. `translateY(-50%)` centering) on an element you then GSAP-animate a transform prop on (`x` / `y` / `scale` / `rotation`): GSAP overwrites the whole `transform` and silently drops the CSS centering (the element jumps). Center with `margin` / `inset` (or `top`/`left` + offset), fold the offset into the tween via `xPercent` / `yPercent`, or use `fromTo` (the rule exempts it). +- `gsap_classname_not_seek_safe` — never animate GSAP's `className` special property (`tl.set(el, { className: "+=locked" })` / `tl.to(el, { className: "active" })`, especially the relative `+=` / `-=` syntax): it assumes sequential playback and does not apply reliably under the frame-by-frame seek, silently degrading and leaving the class unapplied (often rendering as unstyled top-left text) with no lint/validate/runtime error. Toggle the class at the desired time with `tl.call(() => el.classList.add("your-class"))` (or `classList.remove`), or animate the concrete CSS properties directly instead of a class. - **Hero visibility** — the main subject is visible by `t <= 0.5s`; entrance tweens use `fromTo` instead of CSS-hidden starting states. - `exit_animation_on_non_final_scene` — no exit tween unless you are the final frame. - **No front-loading (not a slide)** — the shot's pieces reveal on their `voiceover` cues across the duration, not all fired at `t=0`; a non-still frame keeps content arriving rather than holding a full canvas from ~25%. diff --git a/skills/product-launch-video/sub-agents/frame-worker.md b/skills/product-launch-video/sub-agents/frame-worker.md index 6dad8fb66..c31fd61f4 100644 --- a/skills/product-launch-video/sub-agents/frame-worker.md +++ b/skills/product-launch-video/sub-agents/frame-worker.md @@ -67,6 +67,7 @@ You **can't** meaningfully run `hyperframes lint` / `validate` / `inspect` here: - `timeline_not_paused` / `timeline_not_registered` — one paused timeline, registered at `window.__timelines[""]`. - `css_transition_used` + repeat / yoyo / non-deterministic logic — none present (the renderer seeks frame-by-frame). - `gsap_css_transform_conflict` — never put a CSS `transform` (e.g. `translateY(-50%)` centering) on an element you then GSAP-animate a transform prop on (`x` / `y` / `scale` / `rotation`): GSAP overwrites the whole `transform` and silently drops the CSS centering (the element jumps). Center with `margin` / `inset` (or `top`/`left` + offset), fold the offset into the tween via `xPercent` / `yPercent`, or use `fromTo` (the rule exempts it). +- `gsap_classname_not_seek_safe` — never animate GSAP's `className` special property (`tl.set(el, { className: "+=locked" })` / `tl.to(el, { className: "active" })`, especially the relative `+=` / `-=` syntax): it assumes sequential playback and does not apply reliably under the frame-by-frame seek, silently degrading and leaving the class unapplied (often rendering as unstyled top-left text) with no lint/validate/runtime error. Toggle the class at the desired time with `tl.call(() => el.classList.add("your-class"))` (or `classList.remove`), or animate the concrete CSS properties directly instead of a class. - **Hero visibility** — the main subject is visible by `t <= 0.5s`; entrance tweens use `fromTo` instead of CSS-hidden starting states. - `exit_animation_on_non_final_scene` — no exit tween unless you are the final frame. - **No front-loading (not a slide)** — the shot's pieces reveal on their `voiceover` cues across the duration, not all fired at `t=0`; a non-still frame keeps content arriving rather than holding a full canvas from ~25%.