feat(lint): flag text-reflow props in gsap_non_transform_motion

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) <noreply@anthropic.com>
This commit is contained in:
Vance Ingalls
2026-06-29 10:36:59 -07:00
co-authored by Claude Opus 4.8
parent 58f7dc1c72
commit 082280e8de
5 changed files with 83 additions and 30 deletions
+38
View File
@@ -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 = `
<html><body>
<div data-composition-id="c1" data-width="1920" data-height="1080"><div id="t"></div></div>
<script>
window.__timelines = window.__timelines || {};
const tl = gsap.timeline({ paused: true });
tl.to("#t", { letterSpacing: "-4px", duration: 4, ease: "power1.out" }, 0);
tl.to("#t", { fontSize: 80, duration: 4 }, 0);
window.__timelines["c1"] = tl;
</script>
</body></html>`;
const result = await lintHyperframeHtml(html);
const findings = result.findings.filter((f) => f.code === "gsap_non_transform_motion");
expect(findings).toHaveLength(2);
expect(findings.every((f) => f.severity === "error")).toBe(true);
});
it("gsap_non_transform_motion: text-reflow props are NOT html-in-canvas-exempt", async () => {
const html = `
<html><body>
<div data-composition-id="c1" data-width="1920" data-height="1080">
<canvas layoutsubtree width="1920" height="1080">
<div class="liquid-glass" id="gp1">label</div>
</canvas>
</div>
<script>
window.__timelines = window.__timelines || {};
const tl = gsap.timeline({ paused: true });
tl.to("#gp1", { letterSpacing: "-4px", duration: 4 }, 0);
window.__timelines["c1"] = tl;
</script>
</body></html>`;
const result = await lintHyperframeHtml(html);
const finding = result.findings.find((f) => f.code === "gsap_non_transform_motion");
expect(finding).toBeDefined();
});
it("gsap_non_transform_motion: does NOT fire on the literal text 'roundProps:' inside a string", async () => {
const html = `
<html><body>
+35 -25
View File
@@ -1160,8 +1160,7 @@ export const gsapRules: LintRule<LintContext>[] = [
);
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<string, string[]> = {
left: ["x"],
right: ["x"],
@@ -1173,6 +1172,13 @@ export const gsapRules: LintRule<LintContext>[] = [
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<LintContext>[] = [
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",