@@ -242,20 +242,44 @@ const previewFrames = (page) =>
242242 . filter ( ( u ) => u . includes ( "/preview" ) )
243243 . join ( " " ) ;
244244
245- /** Measures once the preview frames and the box have held still for STILL_MS; Studio updates both after a save. */
245+ /** A hidden preview holding the target is a shadow reload not yet promoted: the visible frame is about to go stale. */
246+ async function hiddenTarget ( page , selector = "#target" ) {
247+ for ( const f of page . frames ( ) . filter ( ( f ) => f . url ( ) . includes ( "/preview" ) ) ) {
248+ const host = await f . frameElement ( ) . catch ( ( ) => null ) ;
249+ const shown = await host ?. evaluate ( ( e ) => e . checkVisibility ( { visibilityProperty : true } ) ) ;
250+ if ( shown === false && ( await f . $ ( selector ) . catch ( ( ) => null ) ) ) return true ;
251+ }
252+ return false ;
253+ }
254+
255+ // Keyframed cases only: waiting out the swap lands a later undo in the preview's burst of requests.
256+ export const swapPending = ( ctx ) => Boolean ( ctx . keys ) && hiddenTarget ( ctx . page , ctx . selector ) ;
257+
258+ /** Restart the stillness window: a pending swap, a changed set of preview frames, or the box moved. */
259+ export const unsettledBy = ( start , now ) =>
260+ start . pending ||
261+ now . pending ||
262+ now . frames !== start . frames ||
263+ quadDistance ( now . m . visible , start . m . visible ) >= 0.01 ;
264+
265+ /** Measures once the shown preview and the box have held still for STILL_MS; Studio updates both after a save. */
246266// fallow-ignore-next-line complexity
247267export async function settled ( ctx , timeout = 15_000 ) {
248268 const deadline = Date . now ( ) + timeout ;
249- let start = { m : await measure ( ctx ) , frames : previewFrames ( ctx . page ) } ;
269+ const read = async ( ) => ( {
270+ m : await measure ( ctx ) ,
271+ frames : previewFrames ( ctx . page ) ,
272+ pending : await swapPending ( ctx ) ,
273+ } ) ;
274+ let start = await read ( ) ;
250275 let now = start ;
251276 // Compared with the window's first read, so a drift too slow to show read to read still restarts it.
252- for ( let since = Date . now ( ) ; Date . now ( ) - since < STILL_MS ; ) {
277+ for ( let since = Date . now ( ) ; start . pending || Date . now ( ) - since < STILL_MS ; ) {
253278 // A preview that never holds still is a Studio defect: the metrics it feeds fail, the rest still count.
254279 if ( Date . now ( ) > deadline ) return { ...now . m , unsettled : true } ;
255280 await nextFrame ( ctx . page ) ;
256- now = { m : await measure ( ctx ) , frames : previewFrames ( ctx . page ) } ;
257- if ( now . frames !== start . frames || quadDistance ( now . m . visible , start . m . visible ) >= 0.01 )
258- [ start , since ] = [ now , Date . now ( ) ] ;
281+ now = await read ( ) ;
282+ if ( unsettledBy ( start , now ) ) [ start , since ] = [ now , Date . now ( ) ] ;
259283 }
260284 return now . m ;
261285}
@@ -268,9 +292,14 @@ export async function openStudio(ctx) {
268292 let seek = null ;
269293 for ( const deadline = Date . now ( ) + 30_000 ; Date . now ( ) < deadline ; await sleep ( 250 ) ) {
270294 seek = await ctx . page
271- . evaluate ( ( time ) => window . __editBench . call ( "studio_seek" , { time } ) , PLAYHEAD )
295+ . evaluate ( ( time ) => window . __editBench . call ( "studio_seek" , { time } ) , ctx . playhead )
272296 . catch ( String ) ;
273- if ( seek ?. ok && seek . duration > 0 && seek . playhead === PLAYHEAD && ( await findTarget ( ctx . page ) ) )
297+ if (
298+ seek ?. ok &&
299+ seek . duration > 0 &&
300+ seek . playhead === ctx . playhead &&
301+ ( await findTarget ( ctx . page ) )
302+ )
274303 break ;
275304 seek = null ;
276305 }
@@ -279,6 +308,75 @@ export async function openStudio(ctx) {
279308 return settled ( ctx ) ;
280309}
281310
311+ async function seekTo ( ctx , time ) {
312+ const seek = await ctx . page . evaluate (
313+ ( t ) => window . __editBench . call ( "studio_seek" , { time : t } ) ,
314+ time ,
315+ ) ;
316+ if ( ! seek ?. ok || seek . playhead !== time )
317+ throw new Error ( `studio_seek ${ time } : ${ JSON . stringify ( seek ) } ` ) ;
318+ return settled ( ctx ) ;
319+ }
320+
321+ /** Each GSAP-animated property's value at every other keyframe time (and the box there), then back to the playhead. */
322+ async function readKeyframes ( ctx , keys , withBox = false ) {
323+ const at = { } ;
324+ for ( const time of keys . times ) {
325+ const m = await seekTo ( ctx , time ) ;
326+ const values = await ctx . handles . target . evaluate ( ( el , props ) => {
327+ const gsap = el . ownerDocument . defaultView . gsap ;
328+ return Object . fromEntries ( props . map ( ( p ) => [ p , Number . parseFloat ( gsap . getProperty ( el , p ) ) ] ) ) ;
329+ } , keys . props ) ;
330+ at [ time ] = { values, ...( withBox && { visible : m . visible } ) } ;
331+ }
332+ await seekTo ( ctx , ctx . playhead ) ;
333+ return at ;
334+ }
335+
336+ // GSAP's own numbers (px, deg, scale): an untouched keyframe reads back exactly.
337+ const KEY_TOLERANCE = 0.01 ;
338+
339+ /** The largest change of an animated value at a keyframe the edit was not on; NaN (unreadable) fails. */
340+ export function keyframeDrift ( before , after ) {
341+ let worst = { diff : 0 , time : null , prop : null } ;
342+ for ( const [ time , b ] of Object . entries ( before ) )
343+ for ( const [ prop , v ] of Object . entries ( b . values ) ) {
344+ const diff = Math . abs ( after [ time ] . values [ prop ] - v ) ;
345+ if ( ! ( diff <= worst . diff ) ) worst = { diff, time : Number ( time ) , prop } ;
346+ }
347+ return { ...worst , pass : worst . diff <= KEY_TOLERANCE } ;
348+ }
349+
350+ const declarations = ( text = "" ) =>
351+ Object . fromEntries (
352+ text
353+ . split ( ";" )
354+ . map ( ( d ) => d . split ( ":" ) )
355+ . filter ( ( d ) => d . length > 1 )
356+ . map ( ( [ k , ...v ] ) => [ k . trim ( ) , v . join ( ":" ) . trim ( ) ] ) ,
357+ ) ;
358+ const capture = ( re , text = "" ) => re . exec ( text ) ?. [ 1 ] ;
359+ const targetCss = ( html ) => ( {
360+ rule : declarations ( capture ( / # t a r g e t \s * \{ ( [ ^ } ] * ) \} / , html ) ) ,
361+ inline : declarations ( capture ( / \b s t y l e = " ( [ ^ " ] * ) " / , capture ( / ( < [ ^ > ] * \b i d = " t a r g e t " [ ^ > ] * > ) / , html ) ) ) ,
362+ } ) ;
363+
364+ /** Plain CSS the edit wrote for a property GSAP animates: it would override or fight the timeline. */
365+ export function strayCss ( original , saved , props ) {
366+ const changed = ( a , b , file , where ) =>
367+ props
368+ . filter ( ( p ) => a [ p ] !== b [ p ] )
369+ . map ( ( p ) => `${ file } ${ where } ${ p } : ${ a [ p ] ?? "-" } -> ${ b [ p ] ?? "-" } ` ) ;
370+ const stray = Object . keys ( original ) . flatMap ( ( file ) => {
371+ const [ a , b ] = [ targetCss ( original [ file ] ) , targetCss ( saved [ file ] ) ] ;
372+ return [
373+ ...changed ( a . rule , b . rule , file , "rule" ) ,
374+ ...changed ( a . inline , b . inline , file , "inline" ) ,
375+ ] ;
376+ } ) ;
377+ return { pass : stray . length === 0 , stray } ;
378+ }
379+
282380/** Puppeteer presses one key at a time: hold the modifiers around the last key. */
283381export async function chord ( page , keys ) {
284382 const [ key , ...mods ] = keys . split ( "+" ) . reverse ( ) ;
@@ -672,7 +770,14 @@ async function nudgeGesture(ctx, pre) {
672770export async function inStudio ( { browser, spec, dir, files, url, evidence } , drive ) {
673771 const context = await browser . createBrowserContext ( ) ;
674772 const page = await context . newPage ( ) ;
675- const ctx = { page, dir, files, handles : null } ;
773+ const ctx = {
774+ page,
775+ dir,
776+ files,
777+ handles : null ,
778+ playhead : spec . playhead ?? PLAYHEAD ,
779+ keys : spec . keys ,
780+ } ;
676781 const consoleErrors = [ ] ;
677782 page . on ( "pageerror" , ( e ) => consoleErrors . push ( e . message ) ) ;
678783 evidence . shots = { } ;
@@ -686,10 +791,12 @@ export async function inStudio({ browser, spec, dir, files, url, evidence }, dri
686791 let pre = await openStudio ( ctx ) ;
687792 await disableSnap ( page ) ;
688793 const zoom = await setZoom ( ctx , spec . zoom , pre . map . toScreen ( centre ( pre . visible ) ) ) ;
794+ // The animated values at the other keyframes, read before anything is selected or edited.
795+ const keysBefore = spec . keys && ( await readKeyframes ( ctx , spec . keys ) ) ;
689796 pre = await settled ( ctx ) ;
690797 await selectTarget ( ctx , pre ) ;
691798 pre = await settled ( ctx ) ;
692- return await drive ( { ctx, page, pre, zoom, shoot, consoleErrors } ) ;
799+ return await drive ( { ctx, page, pre, zoom, shoot, consoleErrors, keysBefore } ) ;
693800 } catch ( error ) {
694801 await shoot ( "error" ) . catch ( ( ) => undefined ) ;
695802 throw error ;
@@ -707,7 +814,7 @@ export async function runCase(args) {
707814// fallow-ignore-next-line complexity
708815async function measureCase (
709816 { spec, dir, files, evidence } ,
710- { ctx, page, pre, zoom, shoot, consoleErrors } ,
817+ { ctx, page, pre, zoom, shoot, consoleErrors, keysBefore } ,
711818 control ,
712819) {
713820 const original = readFiles ( dir , files ) ;
@@ -753,6 +860,8 @@ async function measureCase(
753860 await page . reload ( ) ;
754861 const reloaded = await openStudio ( ctx ) ;
755862 await shoot ( "reloaded" ) ;
863+ // From the saved file: the other keyframes keep their values, and no animated property gets plain CSS.
864+ const keysAfter = spec . keys && ( await readKeyframes ( ctx , spec . keys , true ) ) ;
756865 const quads = Object . fromEntries (
757866 Object . entries ( { pre, committed, undone, redone, reloaded } ) . filter ( ( [ , m ] ) => m ) ,
758867 ) ;
@@ -793,6 +902,11 @@ async function measureCase(
793902 smooth : { ...drive . smooth , control } ,
794903 unsettled : Object . keys ( quads ) . filter ( ( k ) => quads [ k ] . unsettled ) ,
795904 reloaded,
905+ ...( spec . keys && {
906+ keys : keyframeDrift ( keysBefore , keysAfter ) ,
907+ css : strayCss ( original , readFiles ( dir , files ) , spec . keys . css ) ,
908+ keyRender : { time : spec . keys . render , visible : keysAfter [ spec . keys . render ] . visible } ,
909+ } ) ,
796910 diag : {
797911 ...drive . diag ,
798912 consoleErrors : consoleErrors . slice ( 0 , 5 ) ,
0 commit comments