mirror of
https://github.com/heygen-com/hyperframes.git
synced 2026-09-03 04:38:33 +00:00
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:
@@ -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();
|
||||
});
|
||||
});
|
||||
|
||||
@@ -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
@@ -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
@@ -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",
|
||||
|
||||
+38
-11
@@ -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;
|
||||
})();
|
||||
|
||||
+46
-16
@@ -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
@@ -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
@@ -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
@@ -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;
|
||||
|
||||
@@ -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>
|
||||
|
||||
Reference in New Issue
Block a user