Merge pull request #1779 from heygen-com/feat/lint-gsap-non-transform-motion

feat(lint): add gsap_non_transform_motion rule, migrate registry comps to transforms
This commit is contained in:
Vance Ingalls
2026-07-08 01:38:53 -07:00
committed by GitHub
15 changed files with 648 additions and 98 deletions
+254
View File
@@ -1444,4 +1444,258 @@ describe("GSAP rules", () => {
const finding = result.findings.find((f) => f.code === "scene_layer_missing_visibility_kill");
expect(finding).toBeUndefined();
});
it("gsap_non_transform_motion: errors on layout-prop tweens (left/marginLeft) and roundProps", async () => {
const html = `
<html><body>
<div data-composition-id="c1" data-width="1920" data-height="1080">
<div id="a"></div><div id="b"></div><div id="c"></div>
</div>
<script>
window.__timelines = window.__timelines || {};
const tl = gsap.timeline({ paused: true });
tl.fromTo("#a", { left: 0 }, { left: 1300, duration: 5, ease: "expo.out" }, 0);
tl.fromTo("#b", { marginLeft: 0 }, { marginLeft: 1300, duration: 5, ease: "expo.out" }, 0);
tl.fromTo("#c", { x: 0 }, { x: 1300, duration: 5, ease: "expo.out", roundProps: "x" }, 0);
window.__timelines["c1"] = tl;
</script>
</body></html>`;
const result = await lintHyperframeHtml(html);
const findings = result.findings.filter((f) => f.code === "gsap_non_transform_motion");
expect(findings).toHaveLength(3);
expect(findings.every((f) => f.severity === "error")).toBe(true);
});
it("gsap_non_transform_motion: does NOT fire on transform x/y", async () => {
const html = `
<html><body>
<div data-composition-id="c1" data-width="1920" data-height="1080"><div id="a"></div></div>
<script>
window.__timelines = window.__timelines || {};
const tl = gsap.timeline({ paused: true });
tl.fromTo("#a", { x: 0 }, { x: 1300, duration: 5, ease: "expo.out" }, 0);
window.__timelines["c1"] = tl;
</script>
</body></html>`;
const result = await lintHyperframeHtml(html);
const finding = result.findings.find((f) => f.code === "gsap_non_transform_motion");
expect(finding).toBeUndefined();
});
it("gsap_non_transform_motion: does NOT fire on tl.set() (instantaneous, no stutter)", async () => {
const html = `
<html><body>
<div data-composition-id="c1" data-width="1920" data-height="1080"><div id="a"></div></div>
<script>
window.__timelines = window.__timelines || {};
const tl = gsap.timeline({ paused: true });
tl.set("#a", { left: 100 }, 0);
tl.fromTo("#a", { x: 0 }, { x: 1300, duration: 5 }, 0);
window.__timelines["c1"] = tl;
</script>
</body></html>`;
const result = await lintHyperframeHtml(html);
const finding = result.findings.find((f) => f.code === "gsap_non_transform_motion");
expect(finding).toBeUndefined();
});
it("gsap_non_transform_motion: one tween with both a layout prop AND roundProps reports once", async () => {
const html = `
<html><body>
<div data-composition-id="c1" data-width="1920" data-height="1080"><div id="a"></div></div>
<script>
window.__timelines = window.__timelines || {};
const tl = gsap.timeline({ paused: true });
tl.fromTo("#a", { left: 0 }, { left: 1300, duration: 5, roundProps: "left" }, 0);
window.__timelines["c1"] = tl;
</script>
</body></html>`;
const result = await lintHyperframeHtml(html);
const findings = result.findings.filter((f) => f.code === "gsap_non_transform_motion");
expect(findings).toHaveLength(1);
});
it("gsap_non_transform_motion: catches standalone gsap.to() animating a layout prop", async () => {
const html = `
<html><body>
<div data-composition-id="c1" data-width="1920" data-height="1080"><div id="a"></div></div>
<script>
window.__timelines = window.__timelines || {};
const tl = gsap.timeline({ paused: true });
tl.to("#a", { opacity: 1, duration: 1 }, 0);
gsap.to("#a", { left: 1300, duration: 5 });
window.__timelines["c1"] = tl;
</script>
</body></html>`;
const result = await lintHyperframeHtml(html);
const finding = result.findings.find(
(f) => f.code === "gsap_non_transform_motion" && f.selector === "#a",
);
expect(finding).toBeDefined();
});
it("gsap_non_transform_motion: does NOT fire on html-in-canvas elements (<canvas layoutsubtree>)", async () => {
const html = `
<html><body>
<div data-composition-id="c1" data-width="1920" data-height="1080">
<canvas layoutsubtree width="1920" height="1080">
<div class="liquid-glass" id="gp1"></div>
</canvas>
</div>
<script>
window.__timelines = window.__timelines || {};
const tl = gsap.timeline({ paused: true });
tl.to("#gp1", { left: 1340, duration: 5, ease: "expo.out" }, 0);
window.__timelines["c1"] = tl;
</script>
</body></html>`;
const result = await lintHyperframeHtml(html);
const finding = result.findings.find((f) => f.code === "gsap_non_transform_motion");
expect(finding).toBeUndefined();
});
it("gsap_non_transform_motion: still fires on a grouped tween that also targets a plain-DOM element", async () => {
const html = `
<html><body>
<div data-composition-id="c1" data-width="1920" data-height="1080">
<canvas layoutsubtree width="1920" height="1080">
<div class="liquid-glass" id="gp1"></div>
</canvas>
<div id="txt1">card</div>
</div>
<script>
window.__timelines = window.__timelines || {};
const tl = gsap.timeline({ paused: true });
tl.to(["#gp1", "#txt1"], { left: 1340, duration: 5, ease: "expo.out" }, 0);
window.__timelines["c1"] = tl;
</script>
</body></html>`;
const result = await lintHyperframeHtml(html);
const finding = result.findings.find((f) => f.code === "gsap_non_transform_motion");
expect(finding).toBeDefined();
});
it("gsap_non_transform_motion: fires on a label-positioned tl tween (non-numeric timeline position)", async () => {
const html = `
<html><body>
<div data-composition-id="c1" data-width="1920" data-height="1080"><div id="cursor"></div></div>
<script>
window.__timelines = window.__timelines || {};
const tl = gsap.timeline({ paused: true });
tl.addLabel("hold6", 6);
tl.to("#cursor", { left: 500, top: 580, duration: 1 }, "hold6");
window.__timelines["c1"] = tl;
</script>
</body></html>`;
const result = await lintHyperframeHtml(html);
const finding = result.findings.find((f) => f.code === "gsap_non_transform_motion");
expect(finding).toBeDefined();
});
it("gsap_non_transform_motion: fires on a tl tween whose vars contain a nested {} (onComplete body)", async () => {
const html = `
<html><body>
<div data-composition-id="c1" data-width="1920" data-height="1080"><div id="a"></div></div>
<script>
window.__timelines = window.__timelines || {};
const tl = gsap.timeline({ paused: true });
tl.to("#a", { left: 1300, duration: 5, onComplete: function () { window.done = true; } }, 0);
window.__timelines["c1"] = tl;
</script>
</body></html>`;
const result = await lintHyperframeHtml(html);
const finding = result.findings.find((f) => f.code === "gsap_non_transform_motion");
expect(finding).toBeDefined();
});
it("gsap_non_transform_motion: roundProps on an html-in-canvas element still fires (not exempt)", async () => {
const html = `
<html><body>
<div data-composition-id="c1" data-width="1920" data-height="1080">
<canvas layoutsubtree width="1920" height="1080">
<div class="liquid-glass" id="gp1"></div>
</canvas>
</div>
<script>
window.__timelines = window.__timelines || {};
const tl = gsap.timeline({ paused: true });
tl.to("#gp1", { x: 100, duration: 5, roundProps: "x" }, 0);
window.__timelines["c1"] = tl;
</script>
</body></html>`;
const result = await lintHyperframeHtml(html);
const finding = result.findings.find((f) => f.code === "gsap_non_transform_motion");
expect(finding).toBeDefined();
});
it("gsap_non_transform_motion: fires on a layout/reflow prop that appears only in a fromTo's from-object", async () => {
const html = `
<html><body>
<div data-composition-id="c1" data-width="1920" data-height="1080"><div id="t"></div></div>
<script>
window.__timelines = window.__timelines || {};
const tl = gsap.timeline({ paused: true });
tl.fromTo("#t", { left: 100, letterSpacing: "0.3em" }, { 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_non_transform_motion");
expect(finding).toBeDefined();
});
it("gsap_non_transform_motion: fires on text-reflow props (letterSpacing / fontSize)", async () => {
const html = `
<html><body>
<div data-composition-id="c1" data-width="1920" data-height="1080"><div id="t"></div></div>
<script>
window.__timelines = window.__timelines || {};
const tl = gsap.timeline({ paused: true });
tl.to("#t", { letterSpacing: "-4px", duration: 4, ease: "power1.out" }, 0);
tl.to("#t", { fontSize: 80, duration: 4 }, 0);
window.__timelines["c1"] = tl;
</script>
</body></html>`;
const result = await lintHyperframeHtml(html);
const findings = result.findings.filter((f) => f.code === "gsap_non_transform_motion");
expect(findings).toHaveLength(2);
expect(findings.every((f) => f.severity === "error")).toBe(true);
});
it("gsap_non_transform_motion: text-reflow props are NOT html-in-canvas-exempt", async () => {
const html = `
<html><body>
<div data-composition-id="c1" data-width="1920" data-height="1080">
<canvas layoutsubtree width="1920" height="1080">
<div class="liquid-glass" id="gp1">label</div>
</canvas>
</div>
<script>
window.__timelines = window.__timelines || {};
const tl = gsap.timeline({ paused: true });
tl.to("#gp1", { letterSpacing: "-4px", duration: 4 }, 0);
window.__timelines["c1"] = tl;
</script>
</body></html>`;
const result = await lintHyperframeHtml(html);
const finding = result.findings.find((f) => f.code === "gsap_non_transform_motion");
expect(finding).toBeDefined();
});
it("gsap_non_transform_motion: does NOT fire on the literal text 'roundProps:' inside a string", async () => {
const html = `
<html><body>
<div data-composition-id="c1" data-width="1920" data-height="1080"><div id="a"></div></div>
<script>
window.__timelines = window.__timelines || {};
const label = "roundProps: see the docs";
const tl = gsap.timeline({ paused: true });
tl.fromTo("#a", { x: 0 }, { x: 1300, duration: 5 }, 0);
window.__timelines["c1"] = tl;
</script>
</body></html>`;
const result = await lintHyperframeHtml(html);
const finding = result.findings.find((f) => f.code === "gsap_non_transform_motion");
expect(finding).toBeUndefined();
});
});
+181
View File
@@ -4,6 +4,9 @@ interface LintParsedGsap {
method: string;
position: number | string;
properties: Record<string, number | string>;
// fromTo() exposes its first ("from") vars object separately; a layout/reflow prop
// that appears only here still animates and must be checked.
fromProperties?: Record<string, number | string>;
duration?: number;
ease?: string;
extras?: Record<string, unknown>;
@@ -1178,6 +1181,184 @@ export const gsapRules: LintRule<LintContext>[] = [
return findings;
},
// gsap_non_transform_motion — animating layout props (left/top/right/bottom/margin*)
// or using roundProps snaps motion to integer device pixels. On the seek-by-frame
// capture engine this looks smooth at high per-frame deltas (fast tweens) but visibly
// stutters at low deltas (slow tweens / ease-out tails): sub-pixel movement rounds to
// the same pixel for several frames, then jumps a whole pixel. Transforms (x/y/scale)
// interpolate sub-pixel and stay smooth.
//
// EXEMPTION: elements rasterized via the html-in-canvas API — those under a
// `<canvas layoutsubtree>` ancestor (e.g. the liquid-glass blocks) — are NOT laid out
// by the browser compositor. The canvas lib reads getComputedStyle().left/top (a
// sub-pixel value) and draws the element to a bitmap, so animating a layout prop on
// them does not integer-snap and does not stutter. We resolve each tween's target to
// its element(s) and skip the finding only when EVERY target is html-in-canvas; a
// grouped tween that also touches a plain-DOM element (which does stutter) still fires.
//
// No suppression by design: there is intentionally no per-line/per-file opt-out (unlike
// eslint-disable). The stance is fix-the-motion, not silence-the-rule — a plain-DOM
// layout-prop animation always has a faithful transform equivalent (per-glyph x for
// spacing, scale for size, x/y for position). An author who has consciously accepted a
// stutter still has no flag to flip; that is deliberate, not a missing feature.
async ({ scripts, tags, source }) => {
const findings: HyperframeLintFinding[] = [];
// Byte-ranges of every <canvas layoutsubtree>. An element whose open-tag index falls
// inside one of these ranges is html-in-canvas composited.
const layoutSubtreeRanges = tags
.filter((t) => t.name.toLowerCase() === "canvas" && /\blayoutsubtree\b/i.test(t.raw))
.map((t) => ({ start: t.index, end: findTagEnd(source, t) }));
const isHtmlInCanvas = (tag: OpenTag): boolean =>
layoutSubtreeRanges.some((r) => tag.index > r.start && tag.index < r.end);
// Resolve a simple #id / .class token to the element tag(s) it matches.
const tagsByToken = new Map<string, OpenTag[]>();
const addToken = (token: string, tag: OpenTag): void => {
const list = tagsByToken.get(token);
if (list) list.push(tag);
else tagsByToken.set(token, [tag]);
};
for (const tag of tags) {
const id = readAttr(tag.raw, "id");
if (id) addToken(`#${id}`, tag);
for (const cls of readAttr(tag.raw, "class")?.split(/\s+/).filter(Boolean) ?? [])
addToken(`.${cls}`, tag);
}
// True only when the selector resolves to at least one element AND every resolved
// element is html-in-canvas. Unresolvable selectors (no match) are NOT exempt — we
// stay conservative and let the finding fire rather than risk a false negative.
const allTargetsHtmlInCanvas = (selector: string): boolean => {
if (layoutSubtreeRanges.length === 0) return false;
const matched = [...targetedSelectorTokens(selector)].flatMap(
(token) => tagsByToken.get(token) ?? [],
);
return matched.length > 0 && matched.every(isHtmlInCanvas);
};
// Positional layout props → each maps to its transform replacement axis (x/y).
const LAYOUT_FIX: Record<string, string[]> = {
left: ["x"],
right: ["x"],
top: ["y"],
bottom: ["y"],
margin: ["x", "y"],
marginLeft: ["x"],
marginRight: ["x"],
marginTop: ["y"],
marginBottom: ["y"],
};
// Text-reflow props: animating them reflows text and snaps glyph positions to the
// pixel grid, stuttering on slow motion exactly like positional props. They have no
// transform replacement (the fix is to not animate them — settle via scale or hold the
// value), and the snap happens during browser layout, UPSTREAM of any canvas raster, so
// they are never html-in-canvas-exempt. (width/height are deliberately omitted: they
// have legitimate animated uses — progress bars, reveals — and would over-report.)
const REFLOW_PROPS = ["letterSpacing", "wordSpacing", "fontSize"];
// Resolve the parser once, above the loop (the other async rules in this file do the
// same); the dynamic-import cache makes per-iteration calls equivalent, but hoisting
// keeps the placement from reading as load-bearing.
const parseGsapScript = await loadParseGsapScript();
for (const script of scripts) {
if (!/gsap\.timeline/.test(script.content)) continue;
// Two sources: timeline-rooted tweens (tl.to/from/fromTo) and standalone
// gsap.to/from/fromTo calls the acorn parser ignores.
//
// Timeline tweens come straight from the acorn parser's animation list — NOT
// cachedExtractGsapWindows, which drops every tween with a non-numeric timeline
// position (a string label or `+=`/`-=` offset, e.g. `tl.to("#x",{left:9},"hold6")`).
// Position is irrelevant to whether a tween animates a layout prop, so dropping
// those would let real stutter-prone tweens escape. The parser also gives real AST
// keys, so a nested `{}` value (an onComplete body, modifiers) and a layout-prop
// name appearing inside a string value can't be misread — both hazards of a raw scan.
const parsed = parseGsapScript(script.content);
const calls: GsapTransformCall[] = [
...parsed.animations.map((anim) => ({
method: anim.method,
selector: anim.targetSelector,
// Union the from-vars: a fromTo() can animate a layout/reflow prop that appears
// only in its first ("from") object, which is just as stutter-prone as the to-vars.
properties: [
...new Set([
...Object.keys(anim.properties),
...Object.keys(anim.fromProperties ?? {}),
]),
],
raw: synthesizeWindowRaw(parsed.timelineVar, anim),
})),
...extractStandaloneGsapTransformCalls(stripJsComments(script.content)),
];
for (const call of calls) {
// set() is instantaneous — it never animates, so it cannot stutter. A set() that
// seats an integer-snapped layout position (e.g. tl.set("#x",{left:100})) before a
// later transform tween is a single from-state frame, not motion; intentionally skipped.
if (call.method === "set") continue;
// Object.hasOwn, not `in`: a tween property named `toString`/`constructor` would
// match the prototype chain and resolve LAYOUT_FIX[p] to an inherited function.
let layoutProps = call.properties.filter((p) => Object.hasOwn(LAYOUT_FIX, p));
const reflowProps = call.properties.filter((p) => REFLOW_PROPS.includes(p));
const usesRoundProps = call.properties.includes("roundProps");
// Only positional props are html-in-canvas-exempt: the canvas positions the draw
// from sub-pixel computed left/top. Reflow props (glyph layout) and roundProps
// (value rounding) snap upstream of the raster, so they always fire.
if (layoutProps.length > 0 && allTargetsHtmlInCanvas(call.selector)) layoutProps = [];
if (layoutProps.length === 0 && reflowProps.length === 0 && !usesRoundProps) continue;
const flagged = [...layoutProps, ...reflowProps, ...(usesRoundProps ? ["roundProps"] : [])];
const message =
`GSAP tween on "${call.selector}" uses motion that snaps to integer device pixels: ` +
`${flagged.join(", ")}. Layout and text-reflow properties snap during browser layout; ` +
"roundProps rounds the tween value. Slow motion or an ease-out tail then stutters under " +
"the seek-by-frame capture engine — animate transforms (x/y/scale/opacity) instead.";
const fixes: string[] = [];
if (layoutProps.length > 0) {
const tokens = [...new Set(layoutProps.flatMap((p) => LAYOUT_FIX[p] ?? []))];
fixes.push(
`replace ${layoutProps.join("/")} with the transform equivalent (${tokens.join(", ")}) — ` +
`e.g. tl.fromTo("${call.selector}", { x: -1300 }, { x: 0, ...yourAnimation })`,
);
}
if (reflowProps.length > 0) {
// Faithful fix differs by property: fontSize maps to scale (same visual), but
// letterSpacing/wordSpacing do NOT — uniform scale resizes glyphs, it does not
// change the gaps between them. The smooth equivalent of a spacing tween is a
// per-glyph split with an x transform per character.
const sizing = reflowProps.filter((p) => p === "fontSize");
const spacing = reflowProps.filter((p) => p !== "fontSize");
const parts: string[] = [];
if (sizing.length > 0) {
parts.push(`replace ${sizing.join("/")} with scale (same visual, no reflow)`);
}
if (spacing.length > 0) {
parts.push(
`for ${spacing.join("/")}, split the text into per-character elements and animate ` +
"each glyph's x (the spread) — uniform scale is NOT equivalent — or hold the final value statically",
);
}
fixes.push(
`do not animate ${reflowProps.join("/")} (they reflow text and snap glyph positions): ` +
parts.join("; "),
);
}
if (usesRoundProps) fixes.push("remove roundProps");
const fixHint = `${fixes.join("; ")}. Transforms interpolate sub-pixel and stay smooth at any speed.`;
findings.push({
code: "gsap_non_transform_motion",
severity: "error",
message,
selector: call.selector,
fixHint,
snippet: truncateSnippet(call.raw),
});
}
}
return findings;
},
// gsap_group_selector_keyframes
({ scripts }) => {
const findings: HyperframeLintFinding[] = [];
+2 -10
View File
@@ -378,11 +378,7 @@
tl.addLabel("hold3", "+=0.8");
// 9. Cursor drifts in
tl.to(
S + " #cursor",
{ left: 330, top: 1000, duration: 1, ease: "power1.inOut" },
"hold3",
);
tl.to(S + " #cursor", { x: -1110, y: -1560, duration: 1, ease: "power1.inOut" }, "hold3");
// 10. Cursor clicks — selection border
tl.add(() => {
@@ -441,11 +437,7 @@
tl.addLabel("hold6", typingEnd + 0.5);
// 16. Cursor clicks away to deselect
tl.to(
S + " #cursor",
{ left: 390, top: 1040, duration: 0.3, overwrite: "auto" },
"hold6",
);
tl.to(S + " #cursor", { x: -1050, y: -1520, duration: 0.3, overwrite: "auto" }, "hold6");
tl.to(
S + " #cursor",
{ scale: 0.8, duration: 0.05, yoyo: true, repeat: 1, overwrite: "auto" },
+2 -6
View File
@@ -378,11 +378,7 @@
tl.addLabel("hold3", "+=0.8");
// 9. Cursor drifts in
tl.to(
S + " #cursor",
{ left: 450, top: 540, duration: 1, ease: "power1.inOut" },
"hold3",
);
tl.to(S + " #cursor", { x: -1470, y: -540, duration: 1, ease: "power1.inOut" }, "hold3");
// 10. Cursor clicks — selection border
tl.add(() => {
@@ -441,7 +437,7 @@
tl.addLabel("hold6", typingEnd + 0.5);
// 16. Cursor clicks away to deselect
tl.to(S + " #cursor", { left: 500, top: 580, duration: 0.3, overwrite: "auto" }, "hold6");
tl.to(S + " #cursor", { x: -1420, y: -500, duration: 0.3, overwrite: "auto" }, "hold6");
tl.to(
S + " #cursor",
{ scale: 0.8, duration: 0.05, yoyo: true, repeat: 1, overwrite: "auto" },
@@ -543,7 +543,10 @@
y: -8,
transformOrigin: "0% 18%",
});
gsap.set([gf, fileCard], { top: 372, opacity: 1, scale: 1, transformOrigin: "78% 54%" });
// glass (#glass-file) is html-in-canvas — stays on left/top (exempt, no stutter)
gsap.set(gf, { top: 372, opacity: 1, scale: 1, transformOrigin: "78% 54%" });
// plain DOM (#context-file) — CSS resting top is 340px, so top:372 becomes y:32
gsap.set(fileCard, { y: 32, opacity: 1, scale: 1, transformOrigin: "78% 54%" });
gsap.set([gsm, "#submenu-preview"], {
left: 1980,
opacity: 0,
@@ -552,13 +555,16 @@
transformOrigin: "0% 50%",
});
gsap.set(".menu-item", { y: -4, opacity: 0 });
gsap.set("#menu-highlight", { top: 17, opacity: 0 });
// #menu-highlight plain DOM — CSS resting top:17; top:17 seed is y:0, tweens use y
gsap.set("#menu-highlight", { y: 0, opacity: 0 });
tl.to([gf, fileCard], { top: 340, duration: 0.38, ease: "power3.out" }, 0.1);
tl.to(gf, { top: 340, duration: 0.38, ease: "power3.out" }, 0.1);
tl.to(fileCard, { y: 0, duration: 0.38, ease: "power3.out" }, 0.1);
// #pointer plain DOM — CSS resting left:778 top:395; convert left/top → x/y offsets
tl.fromTo(
pointer,
{ left: 620, top: 520 },
{ left: 778, top: 395, duration: 0.55, ease: "power3.out" },
{ x: -158, y: 125 },
{ x: 0, y: 0, duration: 0.55, ease: "power3.out" },
0.35,
);
tl.set("#click-ring", { left: 756, top: 372, scale: 0.72 }, 0.84);
@@ -578,9 +584,9 @@
);
tl.to("#menu-highlight", { opacity: 1, duration: 0.12, ease: "none" }, 1.35);
tl.to("#menu-highlight", { top: 69, duration: 0.22, ease: "power2.out" }, 2.0);
tl.to(pointer, { left: 1128, top: 468, duration: 0.46, ease: "power3.inOut" }, 2.48);
tl.to("#menu-highlight", { top: 187, duration: 0.22, ease: "power2.out" }, 2.58);
tl.to("#menu-highlight", { y: 52, duration: 0.22, ease: "power2.out" }, 2.0);
tl.to(pointer, { x: 350, y: 73, duration: 0.46, ease: "power3.inOut" }, 2.48);
tl.to("#menu-highlight", { y: 170, duration: 0.22, ease: "power2.out" }, 2.58);
tl.set([gsm, "#submenu-preview"], { left: 1236 }, 3.04);
tl.fromTo(
[gsm, "#submenu-preview"],
@@ -588,7 +594,7 @@
{ scale: 1, opacity: 1, x: 0, duration: 0.24, ease: "back.out(1.04)" },
3.08,
);
tl.to(pointer, { left: 1318, top: 484, duration: 0.5, ease: "power3.inOut" }, 3.32);
tl.to(pointer, { x: 540, y: 89, duration: 0.5, ease: "power3.inOut" }, 3.32);
tl.set("#click-ring", { left: 1295, top: 461, scale: 0.72 }, 3.86);
tl.to(
"#click-ring",
@@ -581,13 +581,27 @@
var sliderPct = document.getElementById("slider-pct");
var sliderValue = { value: 34 };
gsap.set([gs, ts, gt, tt, gm, tm, gsl, tsl], { top: 1160, opacity: 1 });
// Glass panels live inside <canvas layoutsubtree> (html-in-canvas): the canvas lib
// reads their sub-pixel computed top/left, so animating layout props on them does
// NOT stutter and is exempt from gsap_non_transform_motion. They keep top/left.
// Plain-DOM text overlays DO stutter on layout props, so they use transforms (x/y).
// CSS resting tops — search 300, toggle 390, media 476, slider 616 — are the
// transform base; the original set seeded top:1160, i.e. y = 1160 - restingTop.
gsap.set([gs, gt, gm, gsl], { top: 1160, opacity: 1 });
gsap.set(ts, { y: 860, opacity: 1 });
gsap.set(tt, { y: 770, opacity: 1 });
gsap.set(tm, { y: 684, opacity: 1 });
gsap.set(tsl, { y: 544, opacity: 1 });
gsap.set("#sound-wave span", { height: 7 });
tl.to([gs, ts], { top: 300, duration: 0.48, ease: "power3.out" }, 0.12);
tl.to([gt, tt], { top: 390, duration: 0.44, ease: "power3.out" }, 0.22);
tl.to([gm, tm], { top: 476, duration: 0.44, ease: "power3.out" }, 0.32);
tl.to([gsl, tsl], { top: 616, duration: 0.46, ease: "power3.out" }, 0.42);
tl.to(gs, { top: 300, duration: 0.48, ease: "power3.out" }, 0.12);
tl.to(ts, { y: 0, duration: 0.48, ease: "power3.out" }, 0.12);
tl.to(gt, { top: 390, duration: 0.44, ease: "power3.out" }, 0.22);
tl.to(tt, { y: 0, duration: 0.44, ease: "power3.out" }, 0.22);
tl.to(gm, { top: 476, duration: 0.44, ease: "power3.out" }, 0.32);
tl.to(tm, { y: 0, duration: 0.44, ease: "power3.out" }, 0.32);
tl.to(gsl, { top: 616, duration: 0.46, ease: "power3.out" }, 0.42);
tl.to(tsl, { y: 0, duration: 0.46, ease: "power3.out" }, 0.42);
tl.call(
function () {
@@ -608,13 +622,13 @@
null,
1.55,
);
tl.to("#toggle-pill", { left: 215, duration: 0.24, ease: "power2.out" }, 1.82);
tl.to("#toggle-pill", { x: 209, duration: 0.24, ease: "power2.out" }, 1.82);
tl.to(
".play-btn",
{ scale: 0.86, duration: 0.12, ease: "power2.out", yoyo: true, repeat: 1 },
2.18,
);
tl.to("#album-sheen", { left: 112, duration: 0.75, ease: "power2.out" }, 2.2);
tl.to("#album-sheen", { x: 182, duration: 0.75, ease: "power2.out" }, 2.2);
tl.to(
"#sound-wave span",
{ height: 15, duration: 0.22, ease: "power2.inOut", stagger: 0.05, yoyo: true, repeat: 12 },
@@ -638,11 +652,24 @@
{ scale: 1.45, duration: 0.24, ease: "power2.out", yoyo: true, repeat: 4 },
2.6,
);
tl.to("#toggle-pill", { left: 425, duration: 0.24, ease: "power2.out" }, 4.15);
tl.to("#toggle-pill", { x: 419, duration: 0.24, ease: "power2.out" }, 4.15);
var stack = [gs, ts, gt, tt, gm, tm, gsl, tsl];
tl.to(stack, { top: "-=8", duration: 0.22, ease: "power2.out", yoyo: true, repeat: 1 }, 5.55);
tl.to(stack, { top: "+=760", duration: 0.42, ease: "power2.in" }, 6.72);
// Settle + exit. Split so glass panels keep relative top (exempt, html-in-canvas)
// and plain-DOM text overlays use the relative transform equivalent (y). Inline
// array literals so the linter can resolve each group's targets; a pre-declared
// array variable resolves to __unresolved__ and would lose the canvas exemption.
tl.to(
[gs, gt, gm, gsl],
{ top: "-=8", duration: 0.22, ease: "power2.out", yoyo: true, repeat: 1 },
5.55,
);
tl.to(
[ts, tt, tm, tsl],
{ y: "-=8", duration: 0.22, ease: "power2.out", yoyo: true, repeat: 1 },
5.55,
);
tl.to([gs, gt, gm, gsl], { top: "+=760", duration: 0.42, ease: "power2.in" }, 6.72);
tl.to([ts, tt, tm, tsl], { y: "+=760", duration: 0.42, ease: "power2.in" }, 6.72);
/* Duration driver */
tl.to({ v: 0 }, { v: 1, duration: DURATION, ease: "none" }, 0);
@@ -402,23 +402,20 @@
var renderStatus = document.getElementById("render-status");
var renderCopy = document.getElementById("render-copy");
gsap.set(["#gp1", "#txt1", "#gp2", "#txt2"], { left: "1980px", opacity: 0 });
gsap.set(["#gp1", "#gp2"], { left: "1980px", opacity: 0 });
gsap.set(["#txt1", "#txt2"], { x: 640, opacity: 0 });
gsap.set("#reply-actions", { y: 8, opacity: 0 });
gsap.set("#typing-dots span", { opacity: 0.35 });
tl.to(
["#gp1", "#txt1"],
{ left: "1326px", opacity: 1, duration: 0.38, ease: "power3.out" },
0.18,
);
tl.to(["#gp1", "#txt1"], { left: "1340px", duration: 0.16, ease: "power2.out" }, 0.56);
tl.to("#gp1", { left: "1326px", opacity: 1, duration: 0.38, ease: "power3.out" }, 0.18);
tl.to("#txt1", { x: -14, opacity: 1, duration: 0.38, ease: "power3.out" }, 0.18);
tl.to("#gp1", { left: "1340px", duration: 0.16, ease: "power2.out" }, 0.56);
tl.to("#txt1", { x: 0, duration: 0.16, ease: "power2.out" }, 0.56);
tl.to(
["#gp2", "#txt2"],
{ left: "1326px", opacity: 1, duration: 0.36, ease: "power3.out" },
0.82,
);
tl.to(["#gp2", "#txt2"], { left: "1340px", duration: 0.16, ease: "power2.out" }, 1.18);
tl.to("#gp2", { left: "1326px", opacity: 1, duration: 0.36, ease: "power3.out" }, 0.82);
tl.to("#txt2", { x: -14, opacity: 1, duration: 0.36, ease: "power3.out" }, 0.82);
tl.to("#gp2", { left: "1340px", duration: 0.16, ease: "power2.out" }, 1.18);
tl.to("#txt2", { x: 0, duration: 0.16, ease: "power2.out" }, 1.18);
tl.to("#render-progress", { width: "100%", duration: 3.4, ease: "none" }, 0.82);
tl.to(
@@ -439,8 +436,10 @@
},
1.2,
);
tl.to(["#gp2", "#txt2"], { top: "+=10", duration: 0.18, ease: "power2.out" }, 2.55);
tl.to(["#gp2", "#txt2"], { top: "-=10", duration: 0.22, ease: "power2.out" }, 2.73);
tl.to("#gp2", { top: "+=10", duration: 0.18, ease: "power2.out" }, 2.55);
tl.to("#txt2", { y: "+=10", duration: 0.18, ease: "power2.out" }, 2.55);
tl.to("#gp2", { top: "-=10", duration: 0.22, ease: "power2.out" }, 2.73);
tl.to("#txt2", { y: "-=10", duration: 0.22, ease: "power2.out" }, 2.73);
tl.to("#reply-actions", { y: 0, opacity: 1, duration: 0.24, ease: "power2.out" }, 2.72);
tl.to(
"#status-dot",
@@ -460,21 +459,20 @@
4.25,
);
tl.to(
["#gp1", "#txt1"],
"#gp1",
{ top: "-=8", duration: 0.2, ease: "power2.out", yoyo: true, repeat: 1 },
4.25,
);
tl.to(
"#txt1",
{ y: "-=8", duration: 0.2, ease: "power2.out", yoyo: true, repeat: 1 },
4.25,
);
tl.to(
["#gp1", "#txt1"],
{ left: "1980px", opacity: 0, duration: 0.42, ease: "power2.in" },
6.72,
);
tl.to(
["#gp2", "#txt2"],
{ left: "1980px", opacity: 0, duration: 0.42, ease: "power2.in" },
6.9,
);
tl.to("#gp1", { left: "1980px", opacity: 0, duration: 0.42, ease: "power2.in" }, 6.72);
tl.to("#txt1", { x: 640, opacity: 0, duration: 0.42, ease: "power2.in" }, 6.72);
tl.to("#gp2", { left: "1980px", opacity: 0, duration: 0.42, ease: "power2.in" }, 6.9);
tl.to("#txt2", { x: 640, opacity: 0, duration: 0.42, ease: "power2.in" }, 6.9);
window.__timelines["liquid-glass-notification"] = tl;
})();
@@ -629,16 +629,35 @@
var batteryCounter = { value: 87 };
var stepsCounter = { value: 8.2 };
var allPanels = [s1g, s1t, s2g, s2t, s3g, s3t, scg, sct, p1g, p1t, p2g, p2t, p3g, p3t];
gsap.set(allPanels, { opacity: 1, top: 1160 });
// Glass panels (rasterized by the html-in-canvas API) animate top — the API
// reads sub-pixel getComputedStyle().top, so layout-prop motion does not
// stutter and the lint rule exempts them. Plain-DOM text overlays DO stutter on
// layout props, so they animate the transform equivalent (y). y is relative to
// each text element's CSS resting top (stats 300, showcase 380, pills 720), so the
// initial seed to visual 1160 is y = 1160 - restingTop.
var glassPanels = [s1g, s2g, s3g, scg, p1g, p2g, p3g];
var statText = [s1t, s2t, s3t];
var showcaseText = sct;
var pillText = [p1t, p2t, p3t];
gsap.set(glassPanels, { opacity: 1, top: 1160 });
gsap.set(statText, { opacity: 1, y: 860 }); // 1160 - 300
gsap.set(showcaseText, { opacity: 1, y: 780 }); // 1160 - 380
gsap.set(pillText, { opacity: 1, y: 440 }); // 1160 - 720
tl.to([s1g, s1t], { top: 300, duration: 0.46, ease: "power3.out" }, 0.12);
tl.to([s2g, s2t], { top: 300, duration: 0.46, ease: "power3.out" }, 0.24);
tl.to([s3g, s3t], { top: 300, duration: 0.46, ease: "power3.out" }, 0.36);
tl.to([scg, sct], { top: 380, duration: 0.5, ease: "power3.out" }, 0.5);
tl.to([p1g, p1t], { top: 720, duration: 0.32, ease: "power3.out" }, 0.9);
tl.to([p2g, p2t], { top: 720, duration: 0.32, ease: "power3.out" }, 1.0);
tl.to([p3g, p3t], { top: 720, duration: 0.32, ease: "power3.out" }, 1.1);
tl.to(s1g, { top: 300, duration: 0.46, ease: "power3.out" }, 0.12);
tl.to(s1t, { y: 0, duration: 0.46, ease: "power3.out" }, 0.12); // 300 - 300
tl.to(s2g, { top: 300, duration: 0.46, ease: "power3.out" }, 0.24);
tl.to(s2t, { y: 0, duration: 0.46, ease: "power3.out" }, 0.24); // 300 - 300
tl.to(s3g, { top: 300, duration: 0.46, ease: "power3.out" }, 0.36);
tl.to(s3t, { y: 0, duration: 0.46, ease: "power3.out" }, 0.36); // 300 - 300
tl.to(scg, { top: 380, duration: 0.5, ease: "power3.out" }, 0.5);
tl.to(sct, { y: 0, duration: 0.5, ease: "power3.out" }, 0.5); // 380 - 380
tl.to(p1g, { top: 720, duration: 0.32, ease: "power3.out" }, 0.9);
tl.to(p1t, { y: 0, duration: 0.32, ease: "power3.out" }, 0.9); // 720 - 720
tl.to(p2g, { top: 720, duration: 0.32, ease: "power3.out" }, 1.0);
tl.to(p2t, { y: 0, duration: 0.32, ease: "power3.out" }, 1.0); // 720 - 720
tl.to(p3g, { top: 720, duration: 0.32, ease: "power3.out" }, 1.1);
tl.to(p3t, { y: 0, duration: 0.32, ease: "power3.out" }, 1.1); // 720 - 720
tl.to("#temp-meter", { width: "76%", duration: 1.7, ease: "power2.out" }, 0.9);
tl.to(
@@ -698,11 +717,8 @@
null,
5.2,
);
tl.to(
[scg, sct],
{ top: "-=10", duration: 0.2, ease: "power2.out", yoyo: true, repeat: 1 },
5.2,
);
tl.to(scg, { top: "-=10", duration: 0.2, ease: "power2.out", yoyo: true, repeat: 1 }, 5.2);
tl.to(sct, { y: "-=10", duration: 0.2, ease: "power2.out", yoyo: true, repeat: 1 }, 5.2);
tl.to(
".pill-dot",
{ scale: 1.55, duration: 0.2, ease: "power2.out", stagger: 0.12, yoyo: true, repeat: 3 },
@@ -710,11 +726,25 @@
);
tl.to(
[s1g, s1t, s2g, s2t, s3g, s3t],
[s1g, s2g, s3g],
{ top: "-=12", duration: 0.2, ease: "power2.out", yoyo: true, repeat: 1 },
5.7,
);
tl.to(allPanels, { top: "+=760", duration: 0.42, ease: "power2.in" }, 6.74);
tl.to(
[s1t, s2t, s3t],
{ y: "-=12", duration: 0.2, ease: "power2.out", yoyo: true, repeat: 1 },
5.7,
);
tl.to(
[s1g, s2g, s3g, scg, p1g, p2g, p3g],
{ top: "+=760", duration: 0.42, ease: "power2.in" },
6.74,
);
tl.to(
[s1t, s2t, s3t, sct, p1t, p2t, p3t],
{ y: "+=760", duration: 0.42, ease: "power2.in" },
6.74,
);
/* Duration driver */
tl.to({ v: 0 }, { v: 1, duration: DURATION, ease: "none" }, 0);
+8 -2
View File
@@ -48,6 +48,7 @@
position: absolute;
top: 0;
bottom: 0;
left: 0;
width: 8px;
background: #ffd23f;
}
@@ -92,12 +93,17 @@
var role = document.getElementById("lt-role");
// An accent sweep crosses left→right; the name is revealed by a clip-path wipe behind it; role fades up.
// Sweep travel: left:"100%" was parent-relative (offsetParent = .lt-namewrap). Since the sweep is
// only 8px wide, xPercent would travel just 8px, so translate by the container width in pixels instead.
// The .lt-namewrap shrink-wraps the name "Dr. Maya Chen" to 536px (no horizontal padding/border, so
// clientWidth == content width), which is exactly what left:"100%" resolved to.
var SWEEP_TRAVEL = 536;
gsap.set(nameEl, { clipPath: "inset(0 100% 0 0)" });
gsap.set(sweep, { left: "0%", opacity: 0 });
gsap.set(sweep, { x: 0, opacity: 0 });
gsap.set(role, { y: 14, opacity: 0 });
tl.to(sweep, { opacity: 1, duration: 0.12, ease: "none" }, 0.1);
tl.to(sweep, { left: "100%", duration: 0.55, ease: "power2.inOut" }, 0.12);
tl.to(sweep, { x: SWEEP_TRAVEL, duration: 0.55, ease: "power2.inOut" }, 0.12);
tl.to(nameEl, { clipPath: "inset(0 0% 0 0)", duration: 0.5, ease: "power2.inOut" }, 0.16);
tl.to(sweep, { opacity: 0, duration: 0.15, ease: "none" }, 0.6);
tl.to(role, { y: 0, opacity: 1, duration: 0.5, ease: "power3.out" }, 0.55);
+22 -4
View File
@@ -41,7 +41,11 @@
<div class="demo-subject"></div>
<div class="demo-text">
<div class="demo-title">VIGNETTE</div>
<div class="demo-title">
<span class="ch">V</span><span class="ch">I</span><span class="ch">G</span
><span class="ch">N</span><span class="ch">E</span><span class="ch">T</span
><span class="ch">T</span><span class="ch">E</span>
</div>
<div class="demo-subtitle">Frame the eye. Hold the moment.</div>
</div>
</div>
@@ -151,6 +155,9 @@
text-shadow: 0 4px 24px rgba(0, 0, 0, 0.55);
opacity: 0;
}
.demo-title .ch {
display: inline-block;
}
.demo-subtitle {
font-size: 30px;
@@ -181,11 +188,22 @@
tl.to(".haze-near", { opacity: 1, duration: 0.8, ease: "power2.out" }, 0.15);
tl.to(".demo-subject", { opacity: 1, scale: 1, duration: 1.0, ease: "power3.out" }, 0.1);
// Title + subtitle settle in.
// Title + subtitle settle in. The letters settle from wider spacing to their
// resting spacing — the faithful smooth equivalent of a letter-spacing tween is a
// per-glyph x spread (transform, sub-pixel smooth), NOT a uniform scale (which
// would resize the glyphs instead of closing the gaps). letter-spacing holds at
// its CSS resting 0.18em; each glyph starts offset by the extra gap the original
// tween opened — (0.32em 0.18em) × 108px ≈ 15.12px per gap — and converges to 0,
// centered about the word (8 chars → center at index 3.5).
tl.fromTo(
".demo-title",
{ opacity: 0, y: 18, letterSpacing: "0.32em" },
{ opacity: 1, y: 0, letterSpacing: "0.18em", duration: 1.0, ease: "power3.out" },
{ opacity: 0, y: 18 },
{ opacity: 1, y: 0, duration: 1.0, ease: "power3.out" },
0.6,
);
tl.from(
".demo-title .ch",
{ x: (i) => (i - 3.5) * 15.12, duration: 1.0, ease: "power3.out" },
0.6,
);
tl.to(".demo-subtitle", { opacity: 1, duration: 0.8, ease: "power3.out" }, 1.0);
@@ -338,8 +338,8 @@
tl.to(
"#cursor",
{
left: 450,
top: 540,
x: -1470,
y: -540,
duration: 1,
ease: "power1.inOut",
},
@@ -409,7 +409,7 @@
tl.addLabel("hold6", typingEnd + 0.5);
// 16. Cursor moves slightly and clicks away to deselect
tl.to("#cursor", { left: 500, top: 580, duration: 0.3, overwrite: "auto" }, "hold6");
tl.to("#cursor", { x: -1420, y: -500, duration: 0.3, overwrite: "auto" }, "hold6");
tl.to(
"#cursor",
{ scale: 0.8, duration: 0.05, yoyo: true, repeat: 1, overwrite: "auto" },
+10 -6
View File
@@ -85,7 +85,7 @@
.curtain {
position: absolute;
top: 0;
left: -100%;
left: 0;
width: 100%;
height: 100%;
background: #1a1a1a;
@@ -145,21 +145,25 @@
<script>
const tl = gsap.timeline({ paused: true });
// Curtains rest off-screen left until their wipes begin.
gsap.set("#curtain-black", { xPercent: -100 });
gsap.set("#curtain-red", { xPercent: -100 });
// Transition: Curtain Pull logic
// A-roll leaves the screen at least once.
// Timing: Let's do a curtain pull at 3.7s (transitioning to the 62% stat)
// and return at 7.3s (end of attention stat).
// Curtain Out (A-roll hidden)
tl.to("#curtain-black", { left: "0%", duration: 0.2, ease: "power2.inOut" }, 3.5);
tl.to("#curtain-black", { xPercent: 0, duration: 0.2, ease: "power2.inOut" }, 3.5);
tl.to("#a-roll-wrapper", { opacity: 0, duration: 0 }, 3.7);
tl.to("#curtain-black", { left: "100%", duration: 0.4, ease: "power2.inOut" }, 3.7);
tl.to("#curtain-black", { xPercent: 100, duration: 0.4, ease: "power2.inOut" }, 3.7);
// Curtain In (A-roll return)
tl.set("#curtain-red", { left: "-100%" }, 7.1);
tl.to("#curtain-red", { left: "0%", duration: 0.2, ease: "power2.inOut" }, 7.1);
tl.set("#curtain-red", { xPercent: -100 }, 7.1);
tl.to("#curtain-red", { xPercent: 0, duration: 0.2, ease: "power2.inOut" }, 7.1);
tl.to("#a-roll-wrapper", { opacity: 1, duration: 0 }, 7.3);
tl.to("#curtain-red", { left: "100%", duration: 0.4, ease: "power2.inOut" }, 7.3);
tl.to("#curtain-red", { xPercent: 100, duration: 0.4, ease: "power2.inOut" }, 7.3);
window.__timelines = window.__timelines || {};
window.__timelines["main-comp"] = tl;
+2 -2
View File
@@ -22,7 +22,7 @@
"files": 1
},
"hyperframes-animation": {
"hash": "0d0bd375f292a04a",
"hash": "373da1c37ee2bd36",
"files": 116
},
"hyperframes-cli": {
@@ -54,7 +54,7 @@
"files": 23
},
"music-to-video": {
"hash": "42aba6b650599962",
"hash": "5c9d4b3307c40a33",
"files": 132
},
"pr-to-video": {
@@ -57,9 +57,41 @@ Animate any custom property. Works for color, length, number — anything CSS wi
## Performance Rules
### Prefer transforms and opacity
### Animate transforms, not layout properties
Animating `x`, `y`, `scale`, `rotation`, `opacity` stays on the GPU compositor. Avoid `width`, `height`, `top`, `left`, `margin`, `padding` when transforms achieve the same effect.
Animate `x`, `y`, `scale`, `rotation`, `opacity`. Never animate `left`, `right`, `top`, `bottom`, `width`, `height`, `margin*`, the text-reflow props `letterSpacing` / `wordSpacing` / `fontSize` — and never `roundProps`.
This is a **render-correctness** rule in HyperFrames, not just a GPU-performance nicety. The renderer seeks frame-by-frame and screenshots each frame, and the browser compositor snaps layout properties to whole device pixels. On a fast tween the per-frame step is several pixels, so the snap is invisible; on a slow tween or a long ease-out tail the value moves less than a pixel per frame — it holds the same pixel for several frames, then jumps a whole one. The result is motion that looks smooth when fast but visibly stutters when slow. Transforms interpolate sub-pixel and stay smooth at any speed. `roundProps` forces the same integer snap onto a transform — don't use it.
"Layout property" is broader than position: anything that triggers **reflow** snaps the same way. `letterSpacing` / `fontSize` are the common trap — a slow "settle" that crawls one of them by a fraction of a pixel per frame dwells on a handful of discrete glyph layouts (visible micro-stutter). The faithful smooth fix depends on which property — **do not reach for `scale` reflexively**:
- **`fontSize`** → animate `scale`. Scaling text up/down is the same visual and stays sub-pixel smooth (no reflow).
- **`letterSpacing` / `wordSpacing`** → uniform `scale` is **not** the same effect (it resizes the glyphs; it does not change the gaps between them). To animate spacing smoothly, split the text into per-character (or per-word) elements and animate each one's `x` — the glyph spread is a transform, sub-pixel smooth and visually identical to a letter-spacing tween. GSAP's `SplitText` does the split. If the spacing change is a minor flourish, hold the final value statically instead.
Unlike positional props, reflow props snap during browser **layout** — upstream of the canvas raster — so they stutter even in html-in-canvas, and the exception below does **not** apply to them.
#### Fixing a flagged animation — preserve the intent
The lint rule tells you a property will stutter; it does **not** tell you the fix, and a fix that merely passes lint can silently change the look. Swapping a `letterSpacing` tighten for a uniform `scale` lints clean but animates a _different thing_ (it resizes the glyphs instead of closing the gaps). Two rules:
1. **Reproduce the same visual** — same start/end state, same trajectory, only sub-pixel-smooth. Use the faithful equivalent (per-glyph `x` for spacing, `scale` for `fontSize`, `x`/`y` for position), not whichever transform is the least code.
2. **Verify against the original, not against the linter.** Render the original and the fixed version and compare the motion at its key moments — the fix should differ only by the removed stutter, not by _where things end up_. Lint-clean-and-smooth is not the bar; faithful-and-smooth is.
If the faithful fix is non-trivial (a per-glyph split, a measured offset), build it or surface the tradeoff — never downgrade to a cheaper, different effect just to satisfy the linter.
**Convert a position animation to a transform** by leaving the element at its resting `left`/`top` in CSS and animating the _offset_ with `x`/`y`:
```javascript
// CSS: #card { left: 1340px; top: 540px } ← resting position stays in CSS
tl.to("#card", { left: 1340, top: 540, duration: 1 }); // ✗ stutters
tl.fromTo("#card", { x: 640, y: 0 }, { x: 0, y: 0, duration: 1 }); // ✓ x/y = delta from CSS rest (640 = startLeft 1340)
```
For a parent-relative `left: "100%"` sweep, use `xPercent: 100` only when the element is the full width of its container; otherwise convert to pixels (`x: containerWidth`).
**The one exception:** elements drawn through the html-in-canvas API — those under a `<canvas layoutsubtree>` ancestor, e.g. the `liquid-glass-*` blocks. The canvas rasterizes from sub-pixel `getComputedStyle`, so layout props don't snap there and those elements keep `left`/`top`. Everything the browser lays out (plain DOM) follows the rule.
The `gsap_non_transform_motion` lint rule is the backstop, not the teacher — reach for transforms from the start instead of animating layout props and waiting for lint to reject them.
### will-change (sparingly)
@@ -84,7 +84,13 @@
{ y: 80, opacity: 0, stagger: 0.045, ease: "back.out(2)", duration: 0.55 },
0.05,
);
tl.to("#hero", { letterSpacing: "0.04em", duration: 0.6, ease: "power2.out" }, 0.7);
// Letters loosen apart as they settle. Animate each glyph's x (a transform), NOT the
// word's letter-spacing: animating letter-spacing reflows text and snaps glyph positions
// to the pixel grid, which micro-stutters on a slow settle under the seek-by-frame
// capture engine (the gsap_non_transform_motion lint rule flags it). The chars are
// already per-glyph spans, so spread them directly: (0.04em (0.04em)) × 280px = 22.4px
// per gap, centered about index 3 of the 7-letter word.
tl.to(".char", { x: (i) => (i - 3) * 22.4, duration: 0.6, ease: "power2.out" }, 0.7);
window.__timelines["main"] = tl;
</script>