From 082280e8de2cc68e0943b4cae755b465aa41547f Mon Sep 17 00:00:00 2001 From: Vance Ingalls Date: Mon, 29 Jun 2026 10:36:59 -0700 Subject: [PATCH] feat(lint): flag text-reflow props in gsap_non_transform_motion MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Extend the rule beyond positional layout props to the text-reflow props letterSpacing / wordSpacing / fontSize. Animating them reflows text and snaps glyph positions to the pixel grid, so a slow ease-out tail micro-stutters exactly like left/top — measured on a real composition, a slow letterSpacing "settle" rendered 19/30 unique frames vs 30/30 for the transform-driven motion in the same piece. They have no transform replacement (fix: settle via scale or hold the value), and the snap happens during browser layout, upstream of the canvas raster, so they are never html-in-canvas-exempt. width/height stay excluded (legit animated uses — progress bars, reveals). Restructure the finding's message/fixHint to compose per category (positional -> x/y; reflow -> scale/hold; roundProps -> remove) instead of a two-branch ternary. Skill guidance (gsap-transforms-and-perf.md) broadened: "layout property" includes reflow; letterSpacing/fontSize named as the settle-trap. Migrate the one surfaced positive: registry/components/vignette/demo.html title settle letterSpacing -> a subtle scale settle (render-verified 20/20 unique frames, smooth). Blast radius across the registry was this one comp, zero false positives. Co-Authored-By: Claude Opus 4.8 (1M context) --- packages/lint/src/rules/gsap.test.ts | 38 ++++++++++++ packages/lint/src/rules/gsap.ts | 60 +++++++++++-------- registry/components/vignette/demo.html | 9 ++- skills-manifest.json | 2 +- .../adapters/gsap-transforms-and-perf.md | 4 +- 5 files changed, 83 insertions(+), 30 deletions(-) diff --git a/packages/lint/src/rules/gsap.test.ts b/packages/lint/src/rules/gsap.test.ts index 5b280df17..85089e8eb 100644 --- a/packages/lint/src/rules/gsap.test.ts +++ b/packages/lint/src/rules/gsap.test.ts @@ -1499,6 +1499,44 @@ describe("GSAP rules", () => { 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 = ` diff --git a/packages/lint/src/rules/gsap.ts b/packages/lint/src/rules/gsap.ts index 42b75d66f..a78c321d6 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."; + `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 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."; + 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 d96e7ef03..9e86d0030 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 b1371c40c..7bb8a33d9 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 2dc673351..d24b086e4 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