From 619a603eabd7d0e3b7ee7b32297e6667acd7ecc5 Mon Sep 17 00:00:00 2001 From: Vance Ingalls Date: Mon, 29 Jun 2026 09:46:02 -0700 Subject: [PATCH 1/7] feat(lint): add gsap_non_transform_motion rule, migrate registry comps to transforms Animating layout properties (left/right/top/bottom/margin*) or using roundProps snaps motion to integer device pixels. Under the seek-by-frame capture engine this is invisible 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. New rule gsap_non_transform_motion (error): - Sources timeline tweens from the acorn parser's full animation list, so label- and relative-positioned tweens (tl.to(..., "label") / "+=1") are covered and real AST keys avoid phantom-prop / nested-brace misreads. - Exempts html-in-canvas elements (a ancestor): they are rasterized from sub-pixel getComputedStyle and do not integer-snap. Per-element resolution, so a grouped glass+text tween still flags the plain-DOM half. roundProps is never exempt (it rounds before the style). - Uses Object.hasOwn for the layout-prop lookup, skips set(), and merges standalone gsap.* calls. Migrated 9 registry compositions off layout-prop animation onto transforms, each render-verified visually identical: - liquid-glass-{notification,widgets,media-controls,context-menu}: plain-DOM text overlays -> transforms; glass panels stay on left/top (canvas-exempt). - vignelli: curtain wipes left:% -> xPercent. - lt-mask-reveal: sweep left:% -> x px. - flowchart, flowchart-vertical, decision-tree: cursor paths left/top -> x/y. Co-Authored-By: Claude Opus 4.8 (1M context) --- packages/lint/src/rules/gsap.test.ts | 200 ++++++++++++++++++ packages/lint/src/rules/gsap.ts | 134 ++++++++++++ .../flowchart-vertical.html | 4 +- registry/blocks/flowchart/flowchart.html | 4 +- .../liquid-glass-context-menu.html | 24 ++- .../liquid-glass-media-controls.html | 49 ++++- .../liquid-glass-notification.html | 50 ++++- .../liquid-glass-widgets.html | 62 ++++-- .../blocks/lt-mask-reveal/lt-mask-reveal.html | 10 +- .../compositions/decision_tree.html | 6 +- registry/examples/vignelli/index.html | 16 +- 11 files changed, 498 insertions(+), 61 deletions(-) diff --git a/packages/lint/src/rules/gsap.test.ts b/packages/lint/src/rules/gsap.test.ts index e9b46325a0..5b280df178 100644 --- a/packages/lint/src/rules/gsap.test.ts +++ b/packages/lint/src/rules/gsap.test.ts @@ -1315,4 +1315,204 @@ 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: 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..42b75d66f4 100644 --- a/packages/lint/src/rules/gsap.ts +++ b/packages/lint/src/rules/gsap.ts @@ -1111,6 +1111,140 @@ 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. + 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); + }; + // Map each flagged layout prop to its transform replacement axis. roundProps is + // handled separately (it has no positional replacement — the fix is to remove it). + const LAYOUT_FIX: Record = { + left: ["x"], + right: ["x"], + top: ["y"], + bottom: ["y"], + margin: ["x", "y"], + marginLeft: ["x"], + marginRight: ["x"], + marginTop: ["y"], + marginBottom: ["y"], + }; + 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 parseGsapScript = await loadParseGsapScript(); + const parsed = parseGsapScript(script.content); + const calls: GsapTransformCall[] = [ + ...parsed.animations.map((anim) => ({ + method: anim.method, + selector: anim.targetSelector, + properties: Object.keys(anim.properties), + raw: synthesizeWindowRaw(parsed.timelineVar, anim), + })), + ...extractStandaloneGsapTransformCalls(stripJsComments(script.content)), + ]; + + for (const call of calls) { + // set() is instantaneous — it never animates, so it cannot stutter. + if (call.method === "set") continue; + const usesRoundProps = call.properties.includes("roundProps"); + // 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)); + // html-in-canvas elements don't integer-snap on layout props (canvas reads + // sub-pixel computed left/top — see EXEMPTION above). roundProps is NOT exempt: + // it rounds the value BEFORE it reaches the style, so the canvas reads the rounded + // value and still stutters (matching this rule's "even on transforms" message). + if (layoutProps.length > 0 && allTargetsHtmlInCanvas(call.selector)) layoutProps = []; + if (layoutProps.length === 0 && !usesRoundProps) continue; + + const message = + layoutProps.length > 0 + ? `GSAP tween animates layout propert${layoutProps.length > 1 ? "ies" : "y"} ` + + `${layoutProps.join(", ")}${usesRoundProps ? " with roundProps" : ""} on ` + + `"${call.selector}". Layout properties snap to integer device pixels, so slow motion ` + + "(or an ease-out tail) stutters under the seek-by-frame capture engine. Animate " + + "transforms instead." + : `GSAP tween uses roundProps on "${call.selector}", which snaps animated values to ` + + "whole integers. Integer snapping stutters under slow motion on the seek-by-frame " + + "capture engine, even on transforms."; + + const fixTokens = [...new Set(layoutProps.flatMap((p) => LAYOUT_FIX[p] ?? []))]; + const fixHint = + layoutProps.length > 0 + ? `Replace ${layoutProps.join("/")} with the transform equivalent (${fixTokens.join(", ")})` + + `${usesRoundProps ? " and remove roundProps" : ""}, e.g. ` + + `tl.fromTo("${call.selector}", { x: -1300 }, { x: 0, ...yourAnimation }). ` + + "Transforms interpolate sub-pixel and stay smooth at any speed." + : "Remove roundProps. Let transforms (x/y/scale) interpolate sub-pixel for smooth motion."; + + 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..6fc4b9b1d8 100644 --- a/registry/blocks/flowchart-vertical/flowchart-vertical.html +++ b/registry/blocks/flowchart-vertical/flowchart-vertical.html @@ -380,7 +380,7 @@ // 9. Cursor drifts in tl.to( S + " #cursor", - { left: 330, top: 1000, duration: 1, ease: "power1.inOut" }, + { x: -1110, y: -1560, duration: 1, ease: "power1.inOut" }, "hold3", ); @@ -443,7 +443,7 @@ // 16. Cursor clicks away to deselect tl.to( S + " #cursor", - { left: 390, top: 1040, duration: 0.3, overwrite: "auto" }, + { x: -1050, y: -1520, duration: 0.3, overwrite: "auto" }, "hold6", ); tl.to( diff --git a/registry/blocks/flowchart/flowchart.html b/registry/blocks/flowchart/flowchart.html index b841901a99..e5f1b02bce 100644 --- a/registry/blocks/flowchart/flowchart.html +++ b/registry/blocks/flowchart/flowchart.html @@ -380,7 +380,7 @@ // 9. Cursor drifts in tl.to( S + " #cursor", - { left: 450, top: 540, duration: 1, ease: "power1.inOut" }, + { x: -1470, y: -540, duration: 1, ease: "power1.inOut" }, "hold3", ); @@ -441,7 +441,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..648e68dd2a 100644 --- a/registry/blocks/liquid-glass-notification/liquid-glass-notification.html +++ b/registry/blocks/liquid-glass-notification/liquid-glass-notification.html @@ -402,23 +402,36 @@ 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"], + "#gp1", { 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( + "#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"], + "#gp2", { 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( + "#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 +452,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 +475,36 @@ 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"], + "#gp1", { left: "1980px", opacity: 0, duration: 0.42, ease: "power2.in" }, 6.72, ); tl.to( - ["#gp2", "#txt2"], + "#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/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 @@ +`; + 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 = ` diff --git a/packages/lint/src/rules/gsap.ts b/packages/lint/src/rules/gsap.ts index 42b75d66f4..a78c321d62 100644 --- a/packages/lint/src/rules/gsap.ts +++ b/packages/lint/src/rules/gsap.ts @@ -1160,8 +1160,7 @@ export const gsapRules: LintRule[] = [ ); return matched.length > 0 && matched.every(isHtmlInCanvas); }; - // Map each flagged layout prop to its transform replacement axis. roundProps is - // handled separately (it has no positional replacement — the fix is to remove it). + // Positional layout props → each maps to its transform replacement axis (x/y). const LAYOUT_FIX: Record = { left: ["x"], right: ["x"], @@ -1173,6 +1172,13 @@ export const gsapRules: LintRule[] = [ 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"]; for (const script of scripts) { if (!/gsap\.timeline/.test(script.content)) continue; @@ -1201,36 +1207,40 @@ export const gsapRules: LintRule[] = [ for (const call of calls) { // set() is instantaneous — it never animates, so it cannot stutter. if (call.method === "set") continue; - const usesRoundProps = call.properties.includes("roundProps"); // 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)); - // html-in-canvas elements don't integer-snap on layout props (canvas reads - // sub-pixel computed left/top — see EXEMPTION above). roundProps is NOT exempt: - // it rounds the value BEFORE it reaches the style, so the canvas reads the rounded - // value and still stutters (matching this rule's "even on transforms" message). + 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 && !usesRoundProps) continue; + if (layoutProps.length === 0 && reflowProps.length === 0 && !usesRoundProps) continue; + const flagged = [...layoutProps, ...reflowProps, ...(usesRoundProps ? ["roundProps"] : [])]; const message = - layoutProps.length > 0 - ? `GSAP tween animates layout propert${layoutProps.length > 1 ? "ies" : "y"} ` + - `${layoutProps.join(", ")}${usesRoundProps ? " with roundProps" : ""} on ` + - `"${call.selector}". Layout properties snap to integer device pixels, so slow motion ` + - "(or an ease-out tail) stutters under the seek-by-frame capture engine. Animate " + - "transforms instead." - : `GSAP tween uses roundProps on "${call.selector}", which snaps animated values to ` + - "whole integers. Integer snapping stutters under slow motion on the seek-by-frame " + - "capture engine, even on transforms."; - - const fixTokens = [...new Set(layoutProps.flatMap((p) => LAYOUT_FIX[p] ?? []))]; - const fixHint = - layoutProps.length > 0 - ? `Replace ${layoutProps.join("/")} with the transform equivalent (${fixTokens.join(", ")})` + - `${usesRoundProps ? " and remove roundProps" : ""}, e.g. ` + - `tl.fromTo("${call.selector}", { x: -1300 }, { x: 0, ...yourAnimation }). ` + - "Transforms interpolate sub-pixel and stay smooth at any speed." - : "Remove roundProps. Let transforms (x/y/scale) interpolate sub-pixel for smooth motion."; + `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) { + fixes.push( + `do not animate ${reflowProps.join("/")} (they reflow text and snap glyph positions) — ` + + "settle via scale, or set the final value statically", + ); + } + 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", diff --git a/registry/components/vignette/demo.html b/registry/components/vignette/demo.html index d96e7ef03e..9e86d00300 100644 --- a/registry/components/vignette/demo.html +++ b/registry/components/vignette/demo.html @@ -181,11 +181,14 @@ 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. Settle via scale (transform), not letterSpacing: + // animating letter-spacing reflows text and snaps glyph positions to the pixel + // grid, so a slow ease-out tail micro-stutters. letter-spacing holds at its CSS + // resting value (0.18em); the subtle scale gives the same "settle into place" feel. 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, scale: 1.04 }, + { opacity: 1, y: 0, scale: 1, 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/skills-manifest.json b/skills-manifest.json index b1371c40c0..7bb8a33d9b 100644 --- a/skills-manifest.json +++ b/skills-manifest.json @@ -18,7 +18,7 @@ "files": 1 }, "hyperframes-animation": { - "hash": "9f0ccb60ff53e739", + "hash": "b15d63381aab5852", "files": 115 }, "hyperframes-cli": { diff --git a/skills/hyperframes-animation/adapters/gsap-transforms-and-perf.md b/skills/hyperframes-animation/adapters/gsap-transforms-and-perf.md index 2dc673351f..d24b086e4e 100644 --- a/skills/hyperframes-animation/adapters/gsap-transforms-and-perf.md +++ b/skills/hyperframes-animation/adapters/gsap-transforms-and-perf.md @@ -59,10 +59,12 @@ Animate any custom property. Works for color, length, number — anything CSS wi ### Animate transforms, not layout properties -Animate `x`, `y`, `scale`, `rotation`, `opacity`. Never animate `left`, `right`, `top`, `bottom`, `width`, `height`, `margin*` — and never `roundProps`. +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 letter-spacing or font-size by a fraction of a pixel per frame dwells on a handful of discrete glyph layouts (visible micro-stutter). For a text settle, animate `scale` (or hold the final value) 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. + **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 From 6e21072f442791339f9af904d34fe4bd129e4d1f Mon Sep 17 00:00:00 2001 From: Vance Ingalls Date: Mon, 29 Jun 2026 10:59:49 -0700 Subject: [PATCH 4/7] fix(lint,skills): make the reflow-prop fix faithful, not a lossy scale swap MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The previous fixHint and skill guidance told agents to "settle via scale" for text-reflow props — but uniform scale resizes the glyphs, it does not change the gaps between them, so swapping a letterSpacing tween for scale lints clean while silently animating a different thing. Make the guidance faithful per property: - fontSize -> scale (same visual, sub-pixel smooth). - letterSpacing / wordSpacing -> split the text into per-character elements and animate each glyph's x (the spread); uniform scale is NOT equivalent. Or hold the value statically if it's a minor flourish. Add a "preserve the intent" principle to gsap-transforms-and-perf.md: a fix must reproduce the same start/end state and trajectory and be verified against the ORIGINAL render, not just pass lint — lint-clean-and-smooth is not the bar, faithful-and-smooth is. Redo the vignette demo title with the faithful per-glyph x spread (15.12px = (0.32em-0.18em)*108px, centered about 8 chars). Render-verified: settled endpoint matches the original at 46.5 dB, settle is smooth (24/24 unique frames). Co-Authored-By: Claude Opus 4.8 (1M context) --- packages/lint/src/rules/gsap.ts | 20 +++++++++++++-- registry/components/vignette/demo.html | 25 +++++++++++++------ skills-manifest.json | 2 +- .../adapters/gsap-transforms-and-perf.md | 16 +++++++++++- 4 files changed, 52 insertions(+), 11 deletions(-) diff --git a/packages/lint/src/rules/gsap.ts b/packages/lint/src/rules/gsap.ts index a78c321d62..baf00a15f6 100644 --- a/packages/lint/src/rules/gsap.ts +++ b/packages/lint/src/rules/gsap.ts @@ -1234,9 +1234,25 @@ export const gsapRules: LintRule[] = [ ); } 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) — ` + - "settle via scale, or set the final value statically", + `do not animate ${reflowProps.join("/")} (they reflow text and snap glyph positions): ` + + parts.join("; "), ); } if (usesRoundProps) fixes.push("remove roundProps"); diff --git a/registry/components/vignette/demo.html b/registry/components/vignette/demo.html index 9e86d00300..61daff5cec 100644 --- a/registry/components/vignette/demo.html +++ b/registry/components/vignette/demo.html @@ -41,7 +41,7 @@
-
VIGNETTE
+
VIGNETTE
Frame the eye. Hold the moment.
@@ -151,6 +151,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,14 +184,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. Settle via scale (transform), not letterSpacing: - // animating letter-spacing reflows text and snaps glyph positions to the pixel - // grid, so a slow ease-out tail micro-stutters. letter-spacing holds at its CSS - // resting value (0.18em); the subtle scale gives the same "settle into place" feel. + // 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, scale: 1.04 }, - { opacity: 1, y: 0, scale: 1, 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/skills-manifest.json b/skills-manifest.json index 7bb8a33d9b..5023145866 100644 --- a/skills-manifest.json +++ b/skills-manifest.json @@ -18,7 +18,7 @@ "files": 1 }, "hyperframes-animation": { - "hash": "b15d63381aab5852", + "hash": "a23faee17396c153", "files": 115 }, "hyperframes-cli": { diff --git a/skills/hyperframes-animation/adapters/gsap-transforms-and-perf.md b/skills/hyperframes-animation/adapters/gsap-transforms-and-perf.md index d24b086e4e..cc932de898 100644 --- a/skills/hyperframes-animation/adapters/gsap-transforms-and-perf.md +++ b/skills/hyperframes-animation/adapters/gsap-transforms-and-perf.md @@ -63,7 +63,21 @@ Animate `x`, `y`, `scale`, `rotation`, `opacity`. Never animate `left`, `right`, 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 letter-spacing or font-size by a fraction of a pixel per frame dwells on a handful of discrete glyph layouts (visible micro-stutter). For a text settle, animate `scale` (or hold the final value) 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. +"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`: From 189d8582f46d74083982a0408fd0f9d973aebf56 Mon Sep 17 00:00:00 2001 From: Vance Ingalls Date: Mon, 29 Jun 2026 11:11:35 -0700 Subject: [PATCH 5/7] fix(lint): catch fromTo from-object layout/reflow props in gsap_non_transform_motion MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The rule sourced a tween's animated props from anim.properties only — the acorn parser's to-vars. A fromTo() exposes its first ("from") vars object separately as anim.fromProperties, so a layout or reflow prop animated only in the from-object (e.g. tl.fromTo("#t", { left: 100, letterSpacing: "0.3em" }, { opacity: 1 })) escaped the rule entirely and shipped stuttering. fromTo is the most common tween form, so this was a real recall hole. Union fromProperties into the checked property set; add the field to the lint parse type. Registry re-scan unchanged (0 comps animate a layout prop only in a from-object today), so no collateral. Known remaining gaps (documented, not fixed): standalone gsap.fromTo only scans its first vars object via regex (and aborts on nested braces); roundProps:true (boolean form) is dropped by the parser. Both are rare and the clean fix is disproportionate to the rule's planned sunset when drawElement render lands. Co-Authored-By: Claude Opus 4.8 (1M context) --- packages/lint/src/rules/gsap.test.ts | 16 ++++++++++++++++ packages/lint/src/rules/gsap.ts | 12 +++++++++++- 2 files changed, 27 insertions(+), 1 deletion(-) diff --git a/packages/lint/src/rules/gsap.test.ts b/packages/lint/src/rules/gsap.test.ts index 85089e8ebd..e452a20e3a 100644 --- a/packages/lint/src/rules/gsap.test.ts +++ b/packages/lint/src/rules/gsap.test.ts @@ -1499,6 +1499,22 @@ describe("GSAP rules", () => { 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 = ` diff --git a/packages/lint/src/rules/gsap.ts b/packages/lint/src/rules/gsap.ts index baf00a15f6..0fa1e8261c 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; @@ -1198,7 +1201,14 @@ export const gsapRules: LintRule[] = [ ...parsed.animations.map((anim) => ({ method: anim.method, selector: anim.targetSelector, - properties: Object.keys(anim.properties), + // 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)), From b95f2ca25e46ac12468ca11efcaf51068de607ae Mon Sep 17 00:00:00 2001 From: Vance Ingalls Date: Mon, 29 Jun 2026 14:46:49 -0700 Subject: [PATCH 6/7] fix(ci): format migrated comps with oxfmt, sync skills manifest MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit CI failures were two root causes (preview-regression was a cascade — its gate mirrors preview-parity, which was skipped because the Preflight lint+format job failed): - Preflight (lint + format): the migrated registry comps (flowchart, flowchart-vertical, liquid-glass-notification, vignette demo) were committed unformatted — the lefthook format step only ran oxfmt on staged TS, not HTML, so `oxfmt --check .` failed in CI. Ran oxfmt on all four. The vignette title's per-glyph spans use oxfmt's ``-split form, which keeps the glyphs adjacent (no inter-element whitespace), so rendering is unchanged. - Skills: manifest in sync: the pre-commit hook's manifest generator and CI's canonical `gen-skills-manifest.ts` produced different content hashes for the same skill; regenerated with the canonical generator so the committed manifest matches what CI computes. Co-Authored-By: Claude Opus 4.8 (1M context) --- .../flowchart-vertical.html | 12 +---- registry/blocks/flowchart/flowchart.html | 6 +-- .../liquid-glass-notification.html | 48 ++++--------------- registry/components/vignette/demo.html | 6 ++- skills-manifest.json | 2 +- 5 files changed, 17 insertions(+), 57 deletions(-) diff --git a/registry/blocks/flowchart-vertical/flowchart-vertical.html b/registry/blocks/flowchart-vertical/flowchart-vertical.html index 6fc4b9b1d8..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", - { x: -1110, y: -1560, 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", - { x: -1050, y: -1520, 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 e5f1b02bce..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", - { x: -1470, y: -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(() => { diff --git a/registry/blocks/liquid-glass-notification/liquid-glass-notification.html b/registry/blocks/liquid-glass-notification/liquid-glass-notification.html index 648e68dd2a..85f968d54d 100644 --- a/registry/blocks/liquid-glass-notification/liquid-glass-notification.html +++ b/registry/blocks/liquid-glass-notification/liquid-glass-notification.html @@ -407,29 +407,13 @@ gsap.set("#reply-actions", { y: 8, opacity: 0 }); gsap.set("#typing-dots span", { opacity: 0.35 }); - 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: "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", - { 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: "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); @@ -485,26 +469,10 @@ 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, - ); + 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/components/vignette/demo.html b/registry/components/vignette/demo.html index 61daff5cec..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.
diff --git a/skills-manifest.json b/skills-manifest.json index 5023145866..c8f2e8fb08 100644 --- a/skills-manifest.json +++ b/skills-manifest.json @@ -18,7 +18,7 @@ "files": 1 }, "hyperframes-animation": { - "hash": "a23faee17396c153", + "hash": "c9bbeb7e68c5854d", "files": 115 }, "hyperframes-cli": { From 1c0eef835f3c30ccae4908b26498e2f7f462c64a Mon Sep 17 00:00:00 2001 From: Vance Ingalls Date: Mon, 29 Jun 2026 15:54:06 -0700 Subject: [PATCH 7/7] refactor(skills,lint): migrate kinetic-letter-in off letterSpacing; document rule design MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The kinetic-letter-in motion-primitive (a music-to-video reference authors copy) animated the word's letterSpacing as a settle — a reflow tween that micro-stutters under seek-by-frame capture, and outside the registry scan so the rule never fired on it. The chars are already per-glyph spans, so migrate the settle to a per-glyph x spread ((0.04em − −0.04em) × 280px = 22.4px/gap, centered about index 3), with a comment naming the hazard and the rule. Render-verified: faithful, smooth. Document two intentional design choices in gsap_non_transform_motion so future readers don't read them as misses: - No per-line/per-file suppression by design — the stance is fix-the-motion, not silence-the-rule; every plain-DOM case has a faithful transform equivalent. - set() is skipped intentionally: a set() that seats an integer-snapped layout position before a later transform tween is a single from-state frame, not motion. Also hoist loadParseGsapScript() above the per-script loop (the other async rules do the same; dynamic-import cache makes it equivalent, but the placement no longer reads as load-bearing). Co-Authored-By: Claude Opus 4.8 (1M context) --- packages/lint/src/rules/gsap.ts | 15 +++++++++++++-- skills-manifest.json | 2 +- .../kinetic-letter-in/index.html | 8 +++++++- 3 files changed, 21 insertions(+), 4 deletions(-) diff --git a/packages/lint/src/rules/gsap.ts b/packages/lint/src/rules/gsap.ts index 0fa1e8261c..4ce58bebd2 100644 --- a/packages/lint/src/rules/gsap.ts +++ b/packages/lint/src/rules/gsap.ts @@ -1128,6 +1128,12 @@ export const gsapRules: LintRule[] = [ // 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[] = []; @@ -1182,6 +1188,10 @@ export const gsapRules: LintRule[] = [ // 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; @@ -1195,7 +1205,6 @@ export const gsapRules: LintRule[] = [ // 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 parseGsapScript = await loadParseGsapScript(); const parsed = parseGsapScript(script.content); const calls: GsapTransformCall[] = [ ...parsed.animations.map((anim) => ({ @@ -1215,7 +1224,9 @@ export const gsapRules: LintRule[] = [ ]; for (const call of calls) { - // set() is instantaneous — it never animates, so it cannot stutter. + // 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. diff --git a/skills-manifest.json b/skills-manifest.json index c8f2e8fb08..e23e6cd391 100644 --- a/skills-manifest.json +++ b/skills-manifest.json @@ -50,7 +50,7 @@ "files": 23 }, "music-to-video": { - "hash": "c188d0d159b926c2", + "hash": "32ddcc8895eb021a", "files": 132 }, "pr-to-video": { diff --git a/skills/music-to-video/references/motion-primitives/kinetic-letter-in/index.html b/skills/music-to-video/references/motion-primitives/kinetic-letter-in/index.html index 5fd91a2629..ef040380a3 100644 --- a/skills/music-to-video/references/motion-primitives/kinetic-letter-in/index.html +++ b/skills/music-to-video/references/motion-primitives/kinetic-letter-in/index.html @@ -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;