From 6e21072f442791339f9af904d34fe4bd129e4d1f Mon Sep 17 00:00:00 2001 From: Vance Ingalls Date: Mon, 29 Jun 2026 10:59:49 -0700 Subject: [PATCH] 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 a78c321d6..baf00a15f 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 9e86d0030..61daff5ce 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 7bb8a33d9..502314586 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 d24b086e4..cc932de89 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`: