@@ -22,7 +22,12 @@ async function loadParseGsapScript(): Promise<(script: string) => LintParsedGsap
2222import type { LintContext } from "../context" ;
2323import type { HyperframeLintFinding , LintRule } from "../types" ;
2424import type { OpenTag } from "../utils" ;
25- import { readAttr , truncateSnippet , WINDOW_TIMELINE_ASSIGN_PATTERN } from "../utils" ;
25+ import {
26+ readAttr ,
27+ truncateSnippet ,
28+ WINDOW_TIMELINE_ASSIGN_PATTERN ,
29+ TIMELINE_REGISTRY_ASSIGN_PATTERN ,
30+ } from "../utils" ;
2631
2732// ── GSAP-specific types ────────────────────────────────────────────────────
2833
@@ -47,6 +52,7 @@ const SCENE_BOUNDARY_EPSILON_SECONDS = 0.05;
4752
4853// ── GSAP parsing utilities ─────────────────────────────────────────────────
4954
55+ // fallow-ignore-next-line complexity
5056function stripJsComments ( source : string ) : string {
5157 let out = "" ;
5258 let i = 0 ;
@@ -161,6 +167,7 @@ function synthesizeWindowRaw(
161167// parser already resolves variable targets (`tl.to(kicker, …)`) to selectors
162168// and excludes non-DOM object-target anchors (`tl.to({ _: 0 }, …)`), so there's
163169// no fragile positional pairing between a regex walk and the parsed list.
170+ // fallow-ignore-next-line complexity
164171async function extractGsapWindows ( script : string ) : Promise < GsapWindow [ ] > {
165172 if ( ! / g s a p \. t i m e l i n e / . test ( script ) ) return [ ] ;
166173 const parseGsapScript = await loadParseGsapScript ( ) ;
@@ -334,6 +341,7 @@ function getSingleClassSelector(selector: string): string | null {
334341 return match ?. groups ?. name || null ;
335342}
336343
344+ // fallow-ignore-next-line complexity
337345function cssTransformToGsapProps ( cssTransform : string ) : string | null {
338346 const parts : string [ ] = [ ] ;
339347
@@ -374,8 +382,10 @@ function cssTransformToGsapProps(cssTransform: string): string | null {
374382
375383// ── GSAP rules ─────────────────────────────────────────────────────────────
376384
385+ // fallow-ignore-next-line complexity
377386export const gsapRules : LintRule < LintContext > [ ] = [
378387 // overlapping_gsap_tweens + gsap_animates_clip_element + unscoped_gsap_selector
388+ // fallow-ignore-next-line complexity
379389 async ( { source, tags, scripts, rootCompositionId } ) => {
380390 const findings : HyperframeLintFinding [ ] = [ ] ;
381391
@@ -505,6 +515,7 @@ export const gsapRules: LintRule<LintContext>[] = [
505515 } ,
506516
507517 // gsap_css_transform_conflict
518+ // fallow-ignore-next-line complexity
508519 async ( { styles, scripts, tags } ) => {
509520 const findings : HyperframeLintFinding [ ] = [ ] ;
510521 const cssTranslateSelectors = new Map < string , string > ( ) ;
@@ -642,6 +653,7 @@ export const gsapRules: LintRule<LintContext>[] = [
642653 } ,
643654
644655 // audio_reactive_single_tween_per_group
656+ // fallow-ignore-next-line complexity
645657 ( { scripts, styles } ) => {
646658 const findings : HyperframeLintFinding [ ] = [ ] ;
647659 const isCaptionFile = styles . some ( ( s ) => / \. c a p t i o n [ - _ ] ? (?: g r o u p | w o r d ) / i. test ( s . content ) ) ;
@@ -813,6 +825,7 @@ export const gsapRules: LintRule<LintContext>[] = [
813825 } ,
814826
815827 // gsap_from_opacity_noop — CSS opacity:0 + gsap.from({opacity:0}) = invisible forever
828+ // fallow-ignore-next-line complexity
816829 async ( { styles, scripts, tags } ) => {
817830 const findings : HyperframeLintFinding [ ] = [ ] ;
818831 const cssOpacityZeroSelectors = new Set < string > ( ) ;
@@ -896,4 +909,39 @@ export const gsapRules: LintRule<LintContext>[] = [
896909 }
897910 return findings ;
898911 } ,
912+
913+ // gsap_studio_edit_blocked
914+ // When a script both registers a timeline on window.__timelines AND contains
915+ // GSAP mutation calls targeting element selectors, Studio's isElementGsapTargeted
916+ // check returns true for those elements and silently skips saving drag/resize
917+ // position changes back to source HTML.
918+ ( { scripts } ) => {
919+ const findings : HyperframeLintFinding [ ] = [ ] ;
920+ const GSAP_MUTATION_SELECTOR_RE = / \. \s * (?: s e t | t o | f r o m | f r o m T o ) \s * \( \s * [ " ' ] ( [ # . ] [ ^ " ' ] + ) [ " ' ] / g;
921+
922+ for ( const script of scripts ) {
923+ const content = stripJsComments ( script . content ) ;
924+ if ( ! TIMELINE_REGISTRY_ASSIGN_PATTERN . test ( content ) ) continue ;
925+
926+ const targets = new Set < string > ( ) ;
927+ let match : RegExpExecArray | null ;
928+ const re = new RegExp ( GSAP_MUTATION_SELECTOR_RE . source , "g" ) ;
929+ while ( ( match = re . exec ( content ) ) !== null ) {
930+ if ( match [ 1 ] ) targets . add ( match [ 1 ] ) ;
931+ }
932+ if ( targets . size === 0 ) continue ;
933+
934+ const selList = [ ...targets ] . map ( ( s ) => `"${ s } "` ) . join ( ", " ) ;
935+ findings . push ( {
936+ code : "gsap_studio_edit_blocked" ,
937+ severity : "warning" ,
938+ message : `GSAP tweens target ${ selList } in a registered timeline. Studio cannot save drag/resize edits to these elements — the runtime skips write-back for any element that appears in a registered window.__timelines timeline.` ,
939+ fixHint :
940+ "The hyperframes runtime registers timelines automatically. Do not add a manual window.__timelines script unless GSAP intentionally controls element positions. " +
941+ "For initial visibility states, use CSS (e.g. opacity:0) instead of gsap.set(). " +
942+ "If GSAP must own these elements' positions, avoid drag-editing them in Studio." ,
943+ } ) ;
944+ }
945+ return findings ;
946+ } ,
899947] ;
0 commit comments