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%.