@@ -170,6 +170,39 @@ function isHiddenGsapState(values: Record<string, string | number>): boolean {
170170 ) ;
171171}
172172
173+ function oneValue (
174+ values : Record < string , string | number > ,
175+ keys : string [ ] ,
176+ ) : string | number | undefined {
177+ for ( const key of keys ) {
178+ const value = values [ key ] ;
179+ if ( value !== undefined ) return value ;
180+ }
181+ return undefined ;
182+ }
183+
184+ function isVisibleGsapState ( values : Record < string , string | number > ) : boolean {
185+ const opacity = oneValue ( values , [ "opacity" , "autoAlpha" ] ) ;
186+ if ( typeof opacity === "number" ) return opacity > 0 ;
187+ if ( typeof opacity === "string" && opacity . trim ( ) ) {
188+ const numeric = Number ( opacity ) ;
189+ if ( Number . isFinite ( numeric ) ) return numeric > 0 ;
190+ }
191+
192+ const visibility = stringValue ( values . visibility ) ?. toLowerCase ( ) ;
193+ if ( visibility === "visible" || visibility === "inherit" ) return true ;
194+
195+ const display = stringValue ( values . display ) ?. toLowerCase ( ) ;
196+ if ( display && display !== "none" ) return true ;
197+
198+ return false ;
199+ }
200+
201+ function makesOverlayVisible ( win : GsapWindow ) : boolean {
202+ if ( win . method === "from" && isHiddenGsapState ( win . propertyValues ) ) return true ;
203+ return isVisibleGsapState ( win . propertyValues ) ;
204+ }
205+
173206function isSceneBoundaryExit ( win : GsapWindow ) : boolean {
174207 if ( win . end <= win . position ) return false ;
175208 if ( win . method !== "to" && win . method !== "fromTo" ) return false ;
@@ -282,6 +315,81 @@ function getSingleClassSelector(selector: string): string | null {
282315 return match ?. groups ?. name || null ;
283316}
284317
318+ function readStyleProperty ( style : string , property : string ) : string | null {
319+ const escapedProperty = property . replace ( / [ . * + ? ^ $ { } ( ) | [ \] \\ ] / g, "\\$&" ) ;
320+ const match = style . match ( new RegExp ( `(?:^|;)\\s*${ escapedProperty } \\s*:\\s*([^;]+)` , "i" ) ) ;
321+ return match ?. [ 1 ] ?. trim ( ) || null ;
322+ }
323+
324+ function cssZero ( value : string | null ) : boolean {
325+ if ( ! value ) return false ;
326+ return / ^ 0 (?: \. 0 + ) ? (?: p x | % | v w | v h | r e m | e m ) ? $ / i. test ( value . trim ( ) ) ;
327+ }
328+
329+ function styleHasHiddenInitialState ( style : string ) : boolean {
330+ const opacity = readStyleProperty ( style , "opacity" ) ;
331+ if ( opacity && Number ( opacity ) === 0 ) return true ;
332+ if ( readStyleProperty ( style , "visibility" ) ?. toLowerCase ( ) === "hidden" ) return true ;
333+ if ( readStyleProperty ( style , "display" ) ?. toLowerCase ( ) === "none" ) return true ;
334+ return false ;
335+ }
336+
337+ function styleHasOpaqueBackground ( style : string ) : boolean {
338+ const background =
339+ readStyleProperty ( style , "background" ) || readStyleProperty ( style , "background-color" ) ;
340+ if ( ! background ) return false ;
341+ const normalized = background . toLowerCase ( ) . replace ( / \s + / g, "" ) ;
342+ if ( normalized === "transparent" || normalized === "none" ) return false ;
343+ if ( / r g b a ? \( [ ^ ) ] * , 0 (?: \. 0 + ) ? \) $ / . test ( normalized ) ) return false ;
344+ if ( / h s l a ? \( [ ^ ) ] * , 0 (?: \. 0 + ) ? \) $ / . test ( normalized ) ) return false ;
345+ return true ;
346+ }
347+
348+ function styleLooksFullFrameOverlay ( style : string ) : boolean {
349+ const position = readStyleProperty ( style , "position" ) ?. toLowerCase ( ) ;
350+ if ( position !== "fixed" && position !== "absolute" ) return false ;
351+ const coversFrame =
352+ cssZero ( readStyleProperty ( style , "inset" ) ) ||
353+ ( cssZero ( readStyleProperty ( style , "top" ) ) &&
354+ cssZero ( readStyleProperty ( style , "right" ) ) &&
355+ cssZero ( readStyleProperty ( style , "bottom" ) ) &&
356+ cssZero ( readStyleProperty ( style , "left" ) ) ) ;
357+ return coversFrame && styleHasOpaqueBackground ( style ) ;
358+ }
359+
360+ function collectSimpleStyleRules ( styles : LintContext [ "styles" ] ) : Map < string , string > {
361+ const rules = new Map < string , string > ( ) ;
362+ for ( const style of styles ) {
363+ for ( const [ , selectorList , body ] of style . content . matchAll ( / ( [ ^ { } ] + ) \{ ( [ ^ } ] + ) \} / g) ) {
364+ if ( ! selectorList || ! body ) continue ;
365+ for ( const selector of selectorList . split ( "," ) ) {
366+ const token = selector . trim ( ) ;
367+ if ( ! / ^ [ # . ] [ A - Z a - z 0 - 9 _ - ] + $ / . test ( token ) ) continue ;
368+ rules . set ( token , `${ rules . get ( token ) || "" } ;${ body } ` ) ;
369+ }
370+ }
371+ }
372+ return rules ;
373+ }
374+
375+ function tagSimpleSelectors ( tag : OpenTag ) : string [ ] {
376+ const selectors : string [ ] = [ ] ;
377+ const id = readAttr ( tag . raw , "id" ) ;
378+ if ( id ) selectors . push ( `#${ id } ` ) ;
379+ const classes = readAttr ( tag . raw , "class" ) ?. split ( / \s + / ) . filter ( Boolean ) ?? [ ] ;
380+ for ( const className of classes ) selectors . push ( `.${ className } ` ) ;
381+ return selectors ;
382+ }
383+
384+ function combinedTagStyle ( tag : OpenTag , styleRules : Map < string , string > ) : string {
385+ const styles = [ readAttr ( tag . raw , "style" ) || "" ] ;
386+ for ( const selector of tagSimpleSelectors ( tag ) ) {
387+ const ruleStyle = styleRules . get ( selector ) ;
388+ if ( ruleStyle ) styles . push ( ruleStyle ) ;
389+ }
390+ return styles . filter ( Boolean ) . join ( ";" ) ;
391+ }
392+
285393// fallow-ignore-next-line complexity
286394function cssTransformToGsapProps ( cssTransform : string ) : string | null {
287395 const parts : string [ ] = [ ] ;
@@ -399,7 +507,7 @@ function extractStandaloneGsapTransformCalls(script: string): GsapTransformCall[
399507export const gsapRules : LintRule < LintContext > [ ] = [
400508 // overlapping_gsap_tweens + gsap_animates_clip_element + unscoped_gsap_selector
401509 // fallow-ignore-next-line complexity
402- async ( { source, tags, scripts, rootCompositionId } ) => {
510+ async ( { source, tags, scripts, styles , rootCompositionId } ) => {
403511 const findings : HyperframeLintFinding [ ] = [ ] ;
404512
405513 // Build clip element selector map
@@ -424,6 +532,8 @@ export const gsapRules: LintRule<LintContext>[] = [
424532
425533 const classUsage = countClassUsage ( tags ) ;
426534 const clipStartBoundariesByComposition = collectClipStartBoundariesByComposition ( source , tags ) ;
535+ const styleRules = collectSimpleStyleRules ( styles ) ;
536+ const reportedVisibleOverlayKeys = new Set < string > ( ) ;
427537
428538 for ( const script of scripts ) {
429539 const localTimelineCompId = readRegisteredTimelineCompositionId ( script . content ) ;
@@ -486,6 +596,59 @@ export const gsapRules: LintRule<LintContext>[] = [
486596 }
487597 }
488598
599+ // gsap_fullscreen_overlay_starts_visible
600+ for ( const tag of tags ) {
601+ const selectors = tagSimpleSelectors ( tag ) ;
602+ if ( selectors . length === 0 ) continue ;
603+ const overlayKey = readAttr ( tag . raw , "id" ) || String ( tag . index ) ;
604+ if ( reportedVisibleOverlayKeys . has ( overlayKey ) ) continue ;
605+ const authoredStyle = combinedTagStyle ( tag , styleRules ) ;
606+ if ( ! authoredStyle || ! styleLooksFullFrameOverlay ( authoredStyle ) ) continue ;
607+ if ( styleHasHiddenInitialState ( authoredStyle ) ) continue ;
608+
609+ const visibilityWindows = gsapWindows
610+ . filter ( ( win ) => {
611+ const tokens = targetedSelectorTokens ( win . targetSelector ) ;
612+ if ( ! selectors . some ( ( selector ) => tokens . has ( selector ) ) ) return false ;
613+ return win . properties . some ( ( prop ) =>
614+ [ "opacity" , "autoAlpha" , "visibility" , "display" ] . includes ( prop ) ,
615+ ) ;
616+ } )
617+ . sort ( ( a , b ) => a . position - b . position ) ;
618+ const startsHiddenAtZero = visibilityWindows . some (
619+ ( win ) =>
620+ win . position <= SCENE_BOUNDARY_EPSILON_SECONDS && isHiddenGsapState ( win . propertyValues ) ,
621+ ) ;
622+ if ( startsHiddenAtZero ) continue ;
623+ const firstVisible = visibilityWindows . find ( ( win ) => makesOverlayVisible ( win ) ) ;
624+ if ( ! firstVisible ) continue ;
625+ const selector =
626+ selectors . find ( ( candidate ) =>
627+ targetedSelectorTokens ( firstVisible . targetSelector ) . has ( candidate ) ,
628+ ) ||
629+ selectors [ 0 ] ||
630+ tag . name ;
631+ const laterHidden = visibilityWindows . some (
632+ ( win ) => win . position >= firstVisible . position && isHiddenGsapState ( win . propertyValues ) ,
633+ ) ;
634+ if ( firstVisible . method !== "from" && ! laterHidden ) continue ;
635+
636+ reportedVisibleOverlayKeys . add ( overlayKey ) ;
637+ findings . push ( {
638+ code : "gsap_fullscreen_overlay_starts_visible" ,
639+ severity : "error" ,
640+ message :
641+ `Full-frame overlay "${ selector } " starts visible before its first GSAP opacity tween at ` +
642+ `${ firstVisible . position . toFixed ( 2 ) } s. It will cover earlier render frames, often as a blank/white video.` ,
643+ selector,
644+ elementId : readAttr ( tag . raw , "id" ) || undefined ,
645+ fixHint :
646+ `Add \`opacity: 0\` to "${ selector } " in CSS/inline styles, or add ` +
647+ `\`tl.set("${ selector } ", { opacity: 0 }, 0)\` before the reveal tween.` ,
648+ snippet : truncateSnippet ( firstVisible . raw ) ,
649+ } ) ;
650+ }
651+
489652 // gsap_animates_clip_element — only error when GSAP animates visibility/display
490653 for ( const win of gsapWindows ) {
491654 const sel = win . targetSelector ;
0 commit comments