feat(lint): add gsap_non_transform_motion rule, migrate registry comps to transforms#1779
feat(lint): add gsap_non_transform_motion rule, migrate registry comps to transforms#1779vanceingalls wants to merge 7 commits into
Conversation
…s 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 <canvas layoutsubtree> 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) <noreply@anthropic.com>
Reframe the gsap "prefer transforms" guidance from a GPU-perf nicety to the real HyperFrames render-correctness reason: the seek-by-frame capture engine integer-snaps layout props, so slow tweens stutter. Add the CSS-rest + x/y offset conversion recipe, the parent-relative %/px note, and the one exception (html-in-canvas <canvas layoutsubtree> elements). Frames the gsap_non_transform_motion lint rule as the backstop, not the teacher, so agents reach for transforms up front instead of relying on lint to reject. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
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>
…e swap 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) <noreply@anthropic.com>
…ransform_motion
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) <noreply@anthropic.com>
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 `</span>`-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) <noreply@anthropic.com>
miga-heygen
left a comment
There was a problem hiding this comment.
Review: gsap_non_transform_motion lint rule + registry migrations
SHA: b95f2ca25e46ac12468ca11efcaf51068de607ae (6 commits)
Verdict: LGTM — well-engineered, well-documented, well-tested. A few observations below, none blocking.
What this PR does
-
Adds a new
gsap_non_transform_motionlint rule (error severity) that catches GSAP tweens animating layout properties (left/right/top/bottom/margin*), text-reflow properties (letterSpacing/wordSpacing/fontSize), or usingroundProps— all of which snap to integer device pixels and cause visible stutter under the seek-by-frame capture engine on slow tweens. -
Migrates 9 registry compositions off layout-prop animation onto transforms, with appropriate per-element handling for html-in-canvas exemptions.
-
Updates the skill guidance (
gsap-transforms-and-perf.md) to teach the rule rationale and the faithful-fix principle.
Positives
-
Excellent html-in-canvas exemption design. Per-element resolution so a grouped tween that mixes canvas-exempt glass panels with plain-DOM text overlays still flags the stutter-prone targets. The
allTargetsHtmlInCanvashelper has the right conservative bias: unresolvable selectors are NOT exempt. -
Object.hasOwninstead ofinfor the layout-prop lookup — prevents prototype chain matches ontoString/constructor. Good defensive programming. -
fromPropertiesunion. The 5th commit closes a real recall hole:fromTo()from-object layout props escaped detection entirely. Nice catch. -
Thorough test coverage. 14 test cases covering: positive detection, negative (transform-only),
set()exemption, combined layout+roundProps dedup, standalonegsap.to(), canvas exemption, grouped tween mixed targets, label-positioned tweens, nested-brace tweens, roundProps-on-canvas (not exempt), from-object-only props, text-reflow props, reflow-not-canvas-exempt, and string-literal false-positive prevention. -
Faithful migration principle in the skill guide. "Lint-clean-and-smooth is not the bar; faithful-and-smooth is." — this is exactly the right framing, and the vignette per-glyph spread fix (commit 4) backs it up with a concrete example.
-
Transform migration math checks out. Verified the cursor offset calculations in
decision-tree(left:450 - CSS rest 1920 = x:-1470,top:540 - CSS rest 1080 = y:-540) and the media-controls text overlay y-offsets (1160 - restingTop). Arithmetic is correct throughout.
Observations (non-blocking)
-
Known gap:
extractStandaloneGsapTransformCallsregex forfromTo. The regex patterngsap\.(set|to|from|fromTo)\s*\(\s*(["'])([^"']+)\2\s*,\s*\{([^{}]*)\}captures only the first{...}body. For a standalonegsap.fromTo("#el", { left: 100 }, { opacity: 1 }), the regex matches the from-object{ left: 100 }(which happens to be what you want), but if the layout prop is in the second objectgsap.fromTo("#el", { opacity: 0 }, { left: 100 })it matches{ opacity: 0 }and misses it. This is documented in the commit messages as a known gap alongsideroundProps: true(boolean form), and the rule's planned sunset makes a disproportionate fix reasonable. Acknowledged. -
loadParseGsapScript()called inside thefor (const script of scripts)loop. The dynamic import is cached by the module system so it's functionally fine, but the call site re-invokesloadParseGsapScript()on every iteration. Other rules in this file call it once before the loop. This is a trivial performance nit — the module cache makes the overhead negligible — but hoisting above the loop would be consistent with the existing pattern. Not worth a re-push. -
Vignette per-glyph layout. The
<span class="ch">elements use oxfmt's</span>split form to keep glyphs adjacent (no inter-element whitespace). The comment in commit 6 documents this, which is great — it prevents a future formatter from accidentally introducing text-node whitespace that would shift the visual. Thedisplay: inline-blockon.chalso ensures the glyph-spreadxtransforms work correctly (transforms don't apply to inline elements). Well done. -
set()calls with layout props on plain DOM pass silently. The rule skipsset()because it's instantaneous and can't stutter — that's correct. Worth noting thatgsap.set("#el", { left: 100 })still integer-snaps the initial position, though for a set (not animated) that's rarely visible. The current behavior is the right call.
CI Status
Most checks pass (Build, Test, Typecheck, Lint, Format, Skills manifest, Preflight, Perf, Preview parity). Several regression shards and cross-platform jobs are still pending. No failures on completed checks.
Clean, well-structured work with solid test coverage and excellent documentation of known gaps and design rationale. The "Deferred" section in the PR body is refreshingly honest about what's intentionally left imperfect given the rule's planned sunset.
— Miga
james-russo-rames-d-jusso
left a comment
There was a problem hiding this comment.
Review: gsap_non_transform_motion lint rule + 9-comp migration
Verdict: LGTM. Genuinely well-shaped rule + faithful migrations + honest deferrals. No blockers. Layering on Miga's review (we converge); a few items below that Miga didn't cover.
✅ Convergence with Miga
I independently verified — and agree with — every observation in Miga's review:
- html-in-canvas exemption design — per-element resolution via
allTargetsHtmlInCanvaswith conservative "unresolved → not exempt" bias is exactly right (packages/lint/src/rules/gsap.ts:1159-1165). Object.hasOwnoverin— correct defensive choice for theLAYOUT_FIXlookup atpackages/lint/src/rules/gsap.ts:1222. Prototype-chain false-positives ontoString/constructorwere a real footgun avoided.fromPropertiesunion —packages/lint/src/rules/gsap.ts:1206-1211closes a real recall hole ontl.fromTo("#a", { left: 0 }, { opacity: 1 }). Commit189d8582is doing real work; the test atgsap.test.ts:1502pins it.- 14-test suite is the right shape — positive, negative, AST-edge (label position, nested
{}onComplete body, string-literal-not-key), exemption proof, and bug-pinning (roundProps-on-canvas still fires; reflow-on-canvas still fires). - Standalone-regex captures only the first
{...}— Miga is right.extractStandaloneGsapTransformCallsatgsap.ts:495matches\{([^{}]*)\}once, so a hypotheticalgsap.fromTo("#el", { opacity: 0 }, { left: 100 })slips through. I grepped registry + skills for that exact pattern — no current offenders (all standalonegsap.fromTocalls use transform-safe props in both objects, e.g.registry/examples/startup-pitch/index.html:755,registry/examples/airbnb-deck/index.html:1306). So it's a theoretical gap today. Acceptable for the documented sunset window.
🟡 Additional observations (non-blocking, in priority order)
-
Skill reference outside
registry/is a known stutter pattern.skills/music-to-video/references/motion-primitives/kinetic-letter-in/index.html:87doestl.to("#hero", { letterSpacing: "0.04em", duration: 0.6, ease: "power2.out" }, 0.7)— a textbook reflow tween the new rule would error on if linted. It hasdata-composition-id="main"so it's a real composition, butskills/is not part of the registry scan that reports "0 offenders." Authors copying the motion-primitive will produce stuttering output and never see the rule fire on their reference. Worth either migrating the example to a per-glyphxsplit (the same fix you used in the vignette demo) or adding a comment naming the stutter and pointing at the rule. Two minutes of work; high educational ROI. -
No escape hatch for legitimate edge cases. The HF lint system has no per-line/per-file suppression mechanism (unlike eslint's
eslint-disable-next-line). For this rule that's mostly fine — the design is "fix it, don't suppress" — but it does mean that any future legitimateleft/topanimation on a plain-DOM element (a context where the canvas exemption doesn't apply and the author has accepted the stutter consciously) has no opt-out short of refactoring. Worth a one-line acknowledgement in the rule's docstring that the "fix the code" stance is intentional, so future authors don't burn time looking for a flag they can flip. -
Cross-repo enforcement is implicit.
@hyperframes/lintships as a published package (v0.7.18), but neitherheygen-clinorhyperframes-gemini-agentcurrently depend on it. Agents that generate HF compositions (Claude/Gemini) won't catch aleft:tween at generation time — only when the resulting HTML hits the HF runtime / capture engine where this rule actually runs. Not a blocker (the rule fires where it matters), just worth being explicit that "the rule lives in HF" doesn't mean "all HF-producing tools enforce it." Future work: pre-publish lint hook in the codegen path. -
loadParseGsapScript()inside the script loop atgsap.ts:1198. Other rules in this file (e.g. thegsap_from_opacity_nooprule that's also async) hoist this call above the per-script loop. The dynamic-import cache makes this functionally equivalent — Miga noted the same — but the inconsistency might bite a future reader who assumes the placement is load-bearing. Not worth a re-push. -
gsap.setwith a layout prop on a non-exempt element is silent. The rule correctly skipsset()because it's instantaneous and can't stutter — butgsap.set("#el", { left: 100 })on a plain-DOM element still seats the initial position with integer-snapped coordinates, which can be visible if the element subsequently animates from there (e.g.tl.setat 0, then atl.to({ x: ... })). The from-state itself is one frame, so it usually doesn't matter; the test atgsap.test.ts:1356confirms it's an intentional skip. Worth a one-line comment in the rule docstring explaining the trade so a future reader doesn't assume it was a miss. -
Sunset coupling assumption. The PR body says "when
drawElementhtml-in-canvas becomes the default render path (#1295 / #1444), layout-prop animation stops stuttering everywhere — at that point downgrade this rule to a warning (or retire it)." That's the right architectural call IF the html-in-canvas path becomes universal. If it ends up gated behind a flag indefinitely (cohort rollout, fallback for unsupported browsers, etc.), the rule stays load-bearing forever and the "Deferred (intentional)" refactoring debt (the two-source merge block, selector-token helpers) accumulates. Suggest a follow-up issue pinned to #1295/#1444 outcomes so the deferral has a tracked owner, not just a comment.
Migration verification
I verified the math on the non-canvas-exempt migrations (the ones that change semantic position, not just CSS rest):
lt-mask-reveal.html—SWEEP_TRAVEL = 536(container shrink-wrap width) replacingleft: "100%"is correct given an 8px sweep + 536px namewrap; the new CSS restleft: 0+x: 0/SWEEP_TRAVELis semantically identical.vignelli/index.html— curtain CSSleft: -100%→left: 0+gsap.set xPercent: -100initialization is equivalent. Wipe trajectoryxPercent: -100 → 0 → 100mirrors the originalleft: "-100%" → "0%" → "100%".vignette/demo.html— the per-glyph<span class="ch">split +x: (i) => (i - 3.5) * 15.12is the canonical faithful fix forletterSpacing: "0.32em" → "0.18em", matching the skill doc's "uniform scale is NOT equivalent" guidance.display: inline-blockon.chis required forxtransforms; correctly included.</span><span>adjacency to suppress text-node whitespace is correctly preserved.
Stamp routing
Per James's directive, I'm not stamping autonomously — vanceingalls is the author + Vance is a trusted teammate, and Miguel-the-human already approved at commit 619a603e (note: that approval is on the FIRST commit, not HEAD; GitHub doesn't auto-refresh, but the substantive work since then — the reflow-prop addition, the from-vars union fix, the skill-doc faithful-fix expansion — is all additive and consistent with what Miguel approved). If a HEAD-fresh approval is wanted, I'll route to OG Rames Jusso (jrusso1020); flagging James only if a teammate explicitly asks.
Reviewed at SHA b95f2ca25e46ac12468ca11efcaf51068de607ae.
— Rames D Jusso
jrusso1020
left a comment
There was a problem hiding this comment.
✅ Approved at b95f2ca2 (HEAD-fresh — supersedes the 619a603e approval, which predated 5 substantive commits that added text-reflow-prop + fromTo-from-object detection to the rule).
Independently verified the rule is sound (additive to RDJ + Miga):
- Flags layout/reflow props (left/top/right/bottom/margin*) on GSAP
to/fromTo, with the html-in-canvas exemption correctly scoped (gsap.ts:1124-26): elements under a<canvas layoutsubtree>ancestor (the liquid-glass blocks) are skipped because the canvas lib readsgetComputedStyle().left/topitself, not the browser compositor. That's the right carve-out — without it the rule would false-positive on the canvas-rendered blocks. fromTo/fromcorrectly exempt (they supply explicit start values), and the fixHint steers authors totl.fromTo(...)as the transform-faithful pattern.- Tests cover the clip-element safe/unsafe matrix + the CSS-transform-collision warn + the fromTo fixHint.
- "0 existing offenders" holds, proven by CI: the rule ships at error-level and core CI is green on the migrated
registry/, so there are genuinely no remainingleft/toptweens outside the canvas exemption.
CI: core green; Fallow audit is the declared-bypass.
Non-blocking followups (agree, fast-follow): RDJ's #1 is the real one — skills/music-to-video/.../kinetic-letter-in/index.html:87 animates letterSpacing as a documented primitive, outside the scan path, so authors copying it produce stutter; worth fixing the reference or noting it. And the cross-repo gap (@hyperframes/lint not yet imported by heygen-cli / hyperframes-gemini-agent → composition-generating agents won't catch left: at generation time) is worth a follow-up.
Merge stays yours.
— Rames
…ocument rule design 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) <noreply@anthropic.com>
jrusso1020
left a comment
There was a problem hiding this comment.
✅ Re-approved at 1c0eef83 (was b95f2ca2). The one-commit delta cleanly lands the non-blocking followups and introduces no rule-logic change — re-verified:
- RDJ's #1 fixed:
kinetic-letter-in/index.htmlmigrated offletterSpacing→ per-glyph.charxtransform ((i) => (i-3)*22.4), with a comment on why (letter-spacing reflows text + stutters on the capture engine). The reference now practices what the rule preaches — authors copying it won't produce stutter. - Escape-hatch story documented (gsap.ts): the "no suppression by design — fix-the-motion, not silence-the-rule" rationale is now in-code, addressing the missing-escape-hatch-docs followup.
loadParseGsapScripthoist (Miga/RDJ nit): moved above the loop — behavior-equivalent (the dynamic-import cache makes per-iteration calls equivalent), just clearer placement.- The rule's core flagging + html-in-canvas exemption (verified at
b95f2ca2) are untouched.
Core CI green; the prior stamp's verification holds and the delta is additive / doc / no-op-refactor. Merge stays yours.
— Rames
miguel-heygen
left a comment
There was a problem hiding this comment.
LGTM on the b95f2ca2 → 1c0eef83 delta. Rames already covered the stamp; adding a compact independent pass.
Audited: skills/music-to-video/references/motion-primitives/kinetic-letter-in/index.html, packages/lint/src/rules/gsap.ts, skills-manifest.json delta only.
What checked out: the kinetic-letter reference is now off the letterSpacing tween and uses per-glyph .char x transforms, so RDJ's copy-paste stutter concern is addressed. The loadParseGsapScript() hoist is behavior-equivalent, and the no-suppression / set() rationale comments document the intended rule stance without changing the flagging logic.
Nit: packages/lint/src/rules/gsap.ts:1184 still has the older broad comment saying text-reflow props have “no transform replacement” and suggests “settle via scale or hold.” That now conflicts with the newer nearby comment and the skill guidance: letterSpacing / wordSpacing do have a faithful transform replacement via per-glyph/per-word x; fontSize maps to scale. Not blocking, but worth aligning so future maintainers don't re-learn the lossy-scale lesson.
Verification: bunx vitest run packages/lint/src/rules/gsap.test.ts → 77/77.
— Magi
Verdict: APPROVE
Reasoning: The re-review delta resolves the previously raised follow-ups without changing the core rule semantics. The remaining issue is stale explanatory prose only, not a correctness or merge blocker.
Why
Animating layout props (left/right/top/bottom/margin*) or using roundProps snaps motion to integer device pixels. Under the seek-by-frame capture engine this is invisible on fast tweens but visibly stutters on slow ones / ease-out tails — sub-pixel movement holds a pixel for several frames, then jumps one. Transforms (x/y/scale) interpolate sub-pixel and stay smooth.
Rule:
gsap_non_transform_motion(error)<canvas layoutsubtree>ancestor): drawn from sub-pixelgetComputedStyle, they don't integer-snap. Per-element resolution, so a grouped glass+text tween still flags the plain-DOM half.roundPropsis never exempt (it rounds before the style).Object.hasOwnlookup, skipsset(), merges standalonegsap.*calls.Migrations (9 comps, each render-verified identical)
liquid-glass-{notification,widgets,media-controls,context-menu}: plain-DOM text overlays → transforms; glass panels stay on left/top (canvas-exempt).vignelli: curtain wipesleft:%→xPercent.lt-mask-reveal: sweepleft:%→xpx.flowchart,flowchart-vertical,decision-tree: cursor pathsleft/top→x/y.Verification
270 lint tests pass; full registry scan reports 0 offenders; oxfmt/oxlint clean.
Note: capture-path coupling
The rule's premise is the screenshot compositor's integer-snap. When
drawElementhtml-in-canvas becomes the default render path (#1295 / #1444), layout-prop animation stops stuttering everywhere — at that point downgrade this rule to a warning (or retire it); the transform migrations stay correct on both paths. Intentionally not built path-aware yet (fast-capture is opt-in).Deferred (intentional)
Fallow flags duplication (the two-source merge block, selector-token helpers) — left as-is given the rule's planned sunset above; not worth refactoring code slated to become a warning.
🤖 Generated with Claude Code