diff --git a/packages/lint/src/rules/gsap.ts b/packages/lint/src/rules/gsap.ts index 0fa1e8261..4ce58bebd 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 c8f2e8fb0..e23e6cd39 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 5fd91a262..ef040380a 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;