diff --git a/packages/lint/src/rules/gsap.test.ts b/packages/lint/src/rules/gsap.test.ts index e9b46325a0..e452a20e3a 100644 --- a/packages/lint/src/rules/gsap.test.ts +++ b/packages/lint/src/rules/gsap.test.ts @@ -1315,4 +1315,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 = ` + +
+
+
+ +`; + 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 = ` + +
+ +`; + 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 = ` + +
+ +`; + 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 = ` + +
+ +`; + 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 = ` + +
+ +`; + 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 ()", async () => { + const 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 = ` + +
+ +
+
+
card
+
+ +`; + 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 = ` + +
+ +`; + 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 = ` + +
+ +`; + 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 = ` + +
+ +
+
+
+ +`; + 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 = ` + +
+ +`; + 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 = ` + +
+ +`; + 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 = ` + +
+ +
label
+
+
+ +`; + 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 = ` + +
+ +`; + const result = await lintHyperframeHtml(html); + const finding = result.findings.find((f) => f.code === "gsap_non_transform_motion"); + expect(finding).toBeUndefined(); + }); }); diff --git a/packages/lint/src/rules/gsap.ts b/packages/lint/src/rules/gsap.ts index ef792db500..4ce58bebd2 100644 --- a/packages/lint/src/rules/gsap.ts +++ b/packages/lint/src/rules/gsap.ts @@ -4,6 +4,9 @@ interface LintParsedGsap { method: string; position: number | string; properties: Record; + // fromTo() exposes its first ("from") vars object separately; a layout/reflow prop + // that appears only here still animates and must be checked. + fromProperties?: Record; duration?: number; ease?: string; extras?: Record; @@ -1111,6 +1114,184 @@ export const gsapRules: LintRule[] = [ 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 + // `` 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 . 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(); + 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 = { + 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[] = []; diff --git a/registry/blocks/flowchart-vertical/flowchart-vertical.html b/registry/blocks/flowchart-vertical/flowchart-vertical.html index 718d215d50..872a16d07e 100644 --- a/registry/blocks/flowchart-vertical/flowchart-vertical.html +++ b/registry/blocks/flowchart-vertical/flowchart-vertical.html @@ -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" }, diff --git a/registry/blocks/flowchart/flowchart.html b/registry/blocks/flowchart/flowchart.html index b841901a99..da31cbcac5 100644 --- a/registry/blocks/flowchart/flowchart.html +++ b/registry/blocks/flowchart/flowchart.html @@ -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" }, diff --git a/registry/blocks/liquid-glass-context-menu/liquid-glass-context-menu.html b/registry/blocks/liquid-glass-context-menu/liquid-glass-context-menu.html index f931072581..a3fd2c0f5d 100644 --- a/registry/blocks/liquid-glass-context-menu/liquid-glass-context-menu.html +++ b/registry/blocks/liquid-glass-context-menu/liquid-glass-context-menu.html @@ -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", diff --git a/registry/blocks/liquid-glass-media-controls/liquid-glass-media-controls.html b/registry/blocks/liquid-glass-media-controls/liquid-glass-media-controls.html index ac6b3907f2..c5fb811d6e 100644 --- a/registry/blocks/liquid-glass-media-controls/liquid-glass-media-controls.html +++ b/registry/blocks/liquid-glass-media-controls/liquid-glass-media-controls.html @@ -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 (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); diff --git a/registry/blocks/liquid-glass-notification/liquid-glass-notification.html b/registry/blocks/liquid-glass-notification/liquid-glass-notification.html index 236fb618bf..85f968d54d 100644 --- a/registry/blocks/liquid-glass-notification/liquid-glass-notification.html +++ b/registry/blocks/liquid-glass-notification/liquid-glass-notification.html @@ -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,22 +459,21 @@ 4.25, ); tl.to( - ["#gp1", "#txt1"], + "#gp1", { top: "-=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, + "#txt1", + { y: "-=8", duration: 0.2, ease: "power2.out", yoyo: true, repeat: 1 }, + 4.25, ); + 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; })(); diff --git a/registry/blocks/liquid-glass-widgets/liquid-glass-widgets.html b/registry/blocks/liquid-glass-widgets/liquid-glass-widgets.html index 169e013601..de6ac24be8 100644 --- a/registry/blocks/liquid-glass-widgets/liquid-glass-widgets.html +++ b/registry/blocks/liquid-glass-widgets/liquid-glass-widgets.html @@ -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); diff --git a/registry/blocks/lt-mask-reveal/lt-mask-reveal.html b/registry/blocks/lt-mask-reveal/lt-mask-reveal.html index b90d424fca..13ce8def3e 100644 --- a/registry/blocks/lt-mask-reveal/lt-mask-reveal.html +++ b/registry/blocks/lt-mask-reveal/lt-mask-reveal.html @@ -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); diff --git a/registry/components/vignette/demo.html b/registry/components/vignette/demo.html index d96e7ef03e..1b61df0cee 100644 --- a/registry/components/vignette/demo.html +++ b/registry/components/vignette/demo.html @@ -41,7 +41,11 @@
-
VIGNETTE
+
+ VIGNETTE +
Frame the eye. Hold the moment.
@@ -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); diff --git a/registry/examples/decision-tree/compositions/decision_tree.html b/registry/examples/decision-tree/compositions/decision_tree.html index eafac1c3a4..296a95a06d 100644 --- a/registry/examples/decision-tree/compositions/decision_tree.html +++ b/registry/examples/decision-tree/compositions/decision_tree.html @@ -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" }, diff --git a/registry/examples/vignelli/index.html b/registry/examples/vignelli/index.html index 0be868cbfb..67222aa482 100644 --- a/registry/examples/vignelli/index.html +++ b/registry/examples/vignelli/index.html @@ -85,7 +85,7 @@ .curtain { position: absolute; top: 0; - left: -100%; + left: 0; width: 100%; height: 100%; background: #1a1a1a; @@ -145,21 +145,25 @@