Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
80 changes: 80 additions & 0 deletions packages/lint/src/rules/gsap.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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 = `
<html><body>
<div data-composition-id="c1" data-width="1920" data-height="1080">
<div id="badge"></div>
</div>
<script>
window.__timelines = window.__timelines || {};
const tl = gsap.timeline({ paused: true });
tl.set("#badge", { className: "+=locked" }, 1.0);
window.__timelines["c1"] = tl;
</script>
</body></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 = `
<html><body>
<div data-composition-id="c1" data-width="1920" data-height="1080">
<div id="badge"></div>
</div>
<script>
window.__timelines = window.__timelines || {};
const tl = gsap.timeline({ paused: true });
tl.to("#badge", { className: "active", duration: 1 }, 0);
window.__timelines["c1"] = tl;
</script>
</body></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 = `
<html><body>
<div data-composition-id="c1" data-width="1920" data-height="1080">
<div id="badge"></div>
</div>
<script>
window.__timelines = window.__timelines || {};
const tl = gsap.timeline({ paused: true });
tl.to("#badge", { x: 100, opacity: 1, duration: 1 }, 0);
window.__timelines["c1"] = tl;
</script>
</body></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 = `
<html><body>
<div data-composition-id="c1" data-width="1920" data-height="1080">
<div id="badge"></div>
</div>
<script>
window.__timelines = window.__timelines || {};
const tl = gsap.timeline({ paused: true });
tl.to("#badge", { className: "active", duration: 1 }, 0);
window.__timelines["c1"] = tl;
</script>
</body></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);
});
});
33 changes: 33 additions & 0 deletions packages/lint/src/rules/gsap.ts
Original file line number Diff line number Diff line change
Expand Up @@ -1151,4 +1151,37 @@ export const gsapRules: LintRule<LintContext>[] = [
}
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;
},
];
6 changes: 3 additions & 3 deletions skills-manifest.json
Original file line number Diff line number Diff line change
Expand Up @@ -6,7 +6,7 @@
"files": 144
},
"faceless-explainer": {
"hash": "edbe47dd738d14d8",
"hash": "6b2bc97b03c20586",
"files": 17
},
"general-video": {
Expand Down Expand Up @@ -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": {
Expand Down
1 change: 1 addition & 0 deletions skills/faceless-explainer/sub-agents/frame-worker.md
Original file line number Diff line number Diff line change
Expand Up @@ -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["<frame_id>"]`.
- `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%.
Expand Down
1 change: 1 addition & 0 deletions skills/pr-to-video/sub-agents/frame-worker.md
Original file line number Diff line number Diff line change
Expand Up @@ -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["<frame_id>"]`.
- `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%.
Expand Down
1 change: 1 addition & 0 deletions skills/product-launch-video/sub-agents/frame-worker.md
Original file line number Diff line number Diff line change
Expand Up @@ -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["<frame_id>"]`.
- `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%.
Expand Down
Loading