@@ -432,9 +432,13 @@ export const RenamingIdContext = createContext<string | null>(null);
432432export const ZoomedContext = createContext ( false ) ;
433433export const WindowFocusedContext = createContext ( true ) ;
434434
435- // Transient set of pane ids that were just created (split or empty-state auto-spawn).
436- // TerminalPanel consumes (and removes) its id on first mount to trigger a spawn animation.
437- export const FreshlySpawnedContext = createContext < Set < string > > ( new Set ( ) ) ;
435+ // Transient map of pane ids that were just created → their spawn direction.
436+ // TerminalPanel consumes (and removes) its id on first mount to trigger a directional spawn animation.
437+ // 'left' — born from horizontal split (new pane appeared to the right of the source)
438+ // 'top' — born from vertical split (new pane appeared below the source)
439+ // 'top-left' — auto-spawned after last-pane kill (diagonal counterpoint to the killed pane's crush to bottom-right)
440+ export type SpawnDirection = 'left' | 'top' | 'top-left' ;
441+ export const FreshlySpawnedContext = createContext < Map < string , SpawnDirection > > ( new Map ( ) ) ;
438442
439443const ARROW_OPPOSITES : Record < string , string > = {
440444 ArrowLeft : 'ArrowRight' , ArrowRight : 'ArrowLeft' ,
@@ -477,26 +481,29 @@ function TerminalPanel({ api }: IDockviewPanelProps) {
477481 } ;
478482 } , [ api . id , panelElements , bumpVersion ] ) ;
479483
480- // Freshly spawned: animate the whole dockview group (header + body) as one unit.
481- // We target api.group.element instead of elRef so the tab header animates too.
482- // Using clip-path (not transform) is deliberate — transforms would make
483- // SelectionOverlay's getBoundingClientRect capture the scaled rect and lag
484- // behind the pane until the animation ends.
484+ // Freshly spawned: animate the whole dockview group (header + body) as one unit
485+ // via a directional clip-path reveal. We target api.group.element instead of elRef
486+ // so the tab header animates too. clip-path (not transform) is deliberate —
487+ // transforms affect getBoundingClientRect, which would make the selection overlay
488+ // lag the pane until the animation ends.
485489 useLayoutEffect ( ( ) => {
486- if ( ! freshlySpawned . has ( api . id ) ) return ;
490+ const direction = freshlySpawned . get ( api . id ) ;
491+ if ( ! direction ) return ;
487492 freshlySpawned . delete ( api . id ) ;
488493 const groupEl = api . group ?. element ;
489494 if ( ! groupEl ) return ;
490- groupEl . classList . add ( 'pane-spawning' ) ;
495+ const className = `pane-spawning-from-${ direction } ` ;
496+ const animationName = `pane-spawn-from-${ direction } ` ;
497+ groupEl . classList . add ( className ) ;
491498 const onEnd = ( ev : AnimationEvent ) => {
492- if ( ev . animationName !== 'pane-spawn' ) return ;
493- groupEl . classList . remove ( 'pane-spawning' ) ;
499+ if ( ev . animationName !== animationName ) return ;
500+ groupEl . classList . remove ( className ) ;
494501 groupEl . removeEventListener ( 'animationend' , onEnd ) ;
495502 } ;
496503 groupEl . addEventListener ( 'animationend' , onEnd ) ;
497504 return ( ) => {
498505 groupEl . removeEventListener ( 'animationend' , onEnd ) ;
499- groupEl . classList . remove ( 'pane-spawning' ) ;
506+ groupEl . classList . remove ( className ) ;
500507 } ;
501508 } , [ api , freshlySpawned ] ) ;
502509
@@ -940,7 +947,10 @@ function KillConfirmOverlay({ confirmKill, panelElements, onCancel }: {
940947} ) {
941948 const [ rect , setRect ] = useState < { top : number ; left : number ; width : number ; height : number } | null > ( null ) ;
942949
943- useEffect ( ( ) => {
950+ // useLayoutEffect (not useEffect) so the initial measurement + re-render happens
951+ // before the browser paints. Otherwise the centered-in-viewport fallback below
952+ // flashes for one frame before the overlay snaps to the panel.
953+ useLayoutEffect ( ( ) => {
944954 const panelEl = resolvePanelElement ( panelElements . get ( confirmKill . id ) ) ;
945955 if ( ! panelEl ) { setRect ( null ) ; return ; }
946956
@@ -976,6 +986,137 @@ function KillConfirmOverlay({ confirmKill, panelElements, onCancel }: {
976986}
977987
978988
989+ // --- Kill animation ---
990+ //
991+ // Orchestrates the visual reclaim when a pane is killed. Captures pre-rects of
992+ // every surviving pane's group element, removes the panel (dockview snaps the
993+ // layout), then:
994+ // - Renders a ghost overlay at the killed pane's position with a clip-path
995+ // crush animation (direction chosen from how the layout actually changed).
996+ // - Applies a FLIP-style clip-path reveal on each grower so its newly claimed
997+ // territory is hidden at start and swept in by the animation. We use
998+ // clip-path (not transform) because transform would corrupt the grower's
999+ // getBoundingClientRect and make SelectionOverlay lag.
1000+ //
1001+ // For the "no surviving panes" case (Case C), we only render the ghost crushing
1002+ // to the bottom-right corner; the auto-spawn handler (onDidRemovePanel) will
1003+ // create a fresh pane tagged with 'top-left' spawn direction.
1004+ function orchestrateKill ( api : DockviewApi , killedId : string ) : void {
1005+ const panel = api . getPanel ( killedId ) ;
1006+ if ( ! panel ) return ;
1007+
1008+ // Respect reduced-motion: do the bare removal with no ghost / no FLIP so
1009+ // reduced-motion users aren't left staring at a static surface-colored rect.
1010+ const reduceMotion = typeof window !== 'undefined'
1011+ && window . matchMedia ?.( '(prefers-reduced-motion: reduce)' ) . matches ;
1012+ if ( reduceMotion ) {
1013+ destroyTerminal ( killedId ) ;
1014+ api . removePanel ( panel ) ;
1015+ return ;
1016+ }
1017+
1018+ const killedGroupEl = panel . api . group ?. element ;
1019+ const killedRect = killedGroupEl ?. getBoundingClientRect ( ) ;
1020+
1021+ // Capture pre-rects of every OTHER panel.
1022+ const preRects = new Map < string , { el : HTMLElement ; rect : DOMRect } > ( ) ;
1023+ for ( const p of api . panels ) {
1024+ if ( p . id === killedId ) continue ;
1025+ const el = p . api . group ?. element ;
1026+ if ( el ) preRects . set ( p . id , { el, rect : el . getBoundingClientRect ( ) } ) ;
1027+ }
1028+
1029+ // Remove the panel. Dockview snaps the layout synchronously.
1030+ destroyTerminal ( killedId ) ;
1031+ api . removePanel ( panel ) ;
1032+
1033+ // Classify growers (panes whose rect changed) and pick the crush direction.
1034+ interface Grower { el : HTMLElement ; preRect : DOMRect ; postRect : DOMRect ; }
1035+ const growers : Grower [ ] = [ ] ;
1036+ for ( const p of api . panels ) {
1037+ const pre = preRects . get ( p . id ) ;
1038+ if ( ! pre ) continue ;
1039+ const postRect = pre . el . getBoundingClientRect ( ) ;
1040+ const dw = postRect . width - pre . rect . width ;
1041+ const dh = postRect . height - pre . rect . height ;
1042+ if ( Math . abs ( dw ) < 0.5 && Math . abs ( dh ) < 0.5 ) continue ;
1043+ growers . push ( { el : pre . el , preRect : pre . rect , postRect } ) ;
1044+ }
1045+
1046+ let crushClass = 'pane-crushing-to-br' ;
1047+ if ( killedRect && growers . length > 0 ) {
1048+ const horizontal = growers . some ( g =>
1049+ Math . abs ( g . postRect . width - g . preRect . width ) > Math . abs ( g . postRect . height - g . preRect . height )
1050+ ) ;
1051+ const killedCenterX = ( killedRect . left + killedRect . right ) / 2 ;
1052+ const killedCenterY = ( killedRect . top + killedRect . bottom ) / 2 ;
1053+ if ( horizontal ) {
1054+ const hasLeft = growers . some ( g => ( g . postRect . left + g . postRect . right ) / 2 < killedCenterX ) ;
1055+ const hasRight = growers . some ( g => ( g . postRect . left + g . postRect . right ) / 2 > killedCenterX ) ;
1056+ if ( hasLeft && hasRight ) crushClass = 'pane-crushing-to-hcenter' ;
1057+ else if ( hasLeft ) crushClass = 'pane-crushing-to-right' ;
1058+ else crushClass = 'pane-crushing-to-left' ;
1059+ } else {
1060+ const hasAbove = growers . some ( g => ( g . postRect . top + g . postRect . bottom ) / 2 < killedCenterY ) ;
1061+ const hasBelow = growers . some ( g => ( g . postRect . top + g . postRect . bottom ) / 2 > killedCenterY ) ;
1062+ if ( hasAbove && hasBelow ) crushClass = 'pane-crushing-to-vcenter' ;
1063+ else if ( hasAbove ) crushClass = 'pane-crushing-down' ;
1064+ else crushClass = 'pane-crushing-up' ;
1065+ }
1066+ }
1067+
1068+ // Mount ghost overlay at the killed pane's pre-removal rect.
1069+ if ( killedRect ) {
1070+ const ghost = document . createElement ( 'div' ) ;
1071+ Object . assign ( ghost . style , {
1072+ position : 'fixed' ,
1073+ top : `${ killedRect . top } px` ,
1074+ left : `${ killedRect . left } px` ,
1075+ width : `${ killedRect . width } px` ,
1076+ height : `${ killedRect . height } px` ,
1077+ background : 'var(--color-surface)' ,
1078+ zIndex : '55' ,
1079+ pointerEvents : 'none' ,
1080+ } ) ;
1081+ ghost . classList . add ( crushClass ) ;
1082+ document . body . appendChild ( ghost ) ;
1083+ const cleanup = ( ) => {
1084+ if ( ghost . isConnected ) ghost . remove ( ) ;
1085+ } ;
1086+ ghost . addEventListener ( 'animationend' , cleanup , { once : true } ) ;
1087+ // Safety: reduced-motion + forwards fill mode still fires animationend, but
1088+ // if the browser ever elides the event (or the element is detached early),
1089+ // a timeout ensures the ghost doesn't linger.
1090+ setTimeout ( cleanup , 1000 ) ;
1091+ }
1092+
1093+ // FLIP each grower via clip-path: start with the new territory clipped away,
1094+ // transition to no clip so the territory sweeps in.
1095+ for ( const { el, preRect, postRect } of growers ) {
1096+ // Clear any in-progress spawn animation on this grower before applying FLIP.
1097+ el . classList . remove ( 'pane-spawning-from-left' , 'pane-spawning-from-top' , 'pane-spawning-from-top-left' ) ;
1098+
1099+ const clipTop = Math . max ( 0 , ( preRect . top - postRect . top ) / postRect . height * 100 ) ;
1100+ const clipBottom = Math . max ( 0 , ( postRect . bottom - preRect . bottom ) / postRect . height * 100 ) ;
1101+ const clipLeft = Math . max ( 0 , ( preRect . left - postRect . left ) / postRect . width * 100 ) ;
1102+ const clipRight = Math . max ( 0 , ( postRect . right - preRect . right ) / postRect . width * 100 ) ;
1103+
1104+ el . style . transition = 'none' ;
1105+ el . style . clipPath = `inset(${ clipTop } % ${ clipRight } % ${ clipBottom } % ${ clipLeft } %)` ;
1106+ // Force reflow so the starting clip is committed before we transition.
1107+ void el . offsetHeight ;
1108+ el . style . transition = 'clip-path 440ms cubic-bezier(0.22, 1, 0.36, 1)' ;
1109+ el . style . clipPath = 'inset(0)' ;
1110+ const cleanup = ( ) => {
1111+ el . style . transition = '' ;
1112+ el . style . clipPath = '' ;
1113+ } ;
1114+ el . addEventListener ( 'transitionend' , cleanup , { once : true } ) ;
1115+ setTimeout ( cleanup , 1000 ) ;
1116+ }
1117+ }
1118+
1119+
9791120// --- Main component ---
9801121
9811122export function Pond ( {
@@ -1007,9 +1148,10 @@ export function Pond({
10071148 // Nothing reads this yet; it exists so future work can copy cwd / terminal kind from the source.
10081149 const paneToCopyByIdRef = useRef ( new Map < string , string > ( ) ) ;
10091150
1010- // Ids of panes that were just spawned (split or empty-state auto-spawn). TerminalPanel
1011- // consumes its id on first mount to play an entrance animation.
1012- const freshlySpawnedRef = useRef ( new Set < string > ( ) ) ;
1151+ // Ids of panes that were just spawned, keyed by id with the direction the spawn
1152+ // should reveal from. TerminalPanel consumes its id on first mount to play the
1153+ // matching directional entrance animation.
1154+ const freshlySpawnedRef = useRef ( new Map < string , SpawnDirection > ( ) ) ;
10131155
10141156 // Consumed once in handleReady to restore existing sessions
10151157 const initialPaneIdsRef = useRef ( initialPaneIds ) ;
@@ -1336,7 +1478,7 @@ export function Pond({
13361478 if ( e . api . totalPanels === 0 ) {
13371479 const id = generatePaneId ( ) ;
13381480 paneToCopyByIdRef . current . set ( id , removed . id ) ;
1339- freshlySpawnedRef . current . add ( id ) ;
1481+ freshlySpawnedRef . current . set ( id , 'top-left' ) ;
13401482 e . api . addPanel ( { id, component : 'terminal' , tabComponent : 'terminal' , title : '<unnamed>' } ) ;
13411483 selectPanel ( id ) ;
13421484 }
@@ -1444,18 +1586,16 @@ export function Pond({
14441586 return ;
14451587 }
14461588 if ( e . key . toLowerCase ( ) === ck . char . toLowerCase ( ) ) {
1447- const panel = api . getPanel ( ck . id ) ;
1448- if ( panel ) {
1449- destroyTerminal ( ck . id ) ;
1450- api . removePanel ( panel ) ;
1451- }
1452- // Select next panel
1589+ // Dismiss the modal first so it doesn't hover over the kill animation,
1590+ // then orchestrate the ghost-crush + FLIP reveal on survivors.
1591+ setConfirmKill ( null ) ;
1592+ orchestrateKill ( api , ck . id ) ;
1593+ // Select next panel (or null while the auto-spawn is about to fire).
14531594 if ( api . panels . length > 0 ) {
14541595 selectPanel ( api . panels [ 0 ] . id ) ;
14551596 } else {
14561597 setSelectedId ( null ) ;
14571598 }
1458- setConfirmKill ( null ) ;
14591599 return ;
14601600 }
14611601 // Wrong key — shake then dismiss
@@ -1782,7 +1922,9 @@ export function Pond({
17821922 const newId = generatePaneId ( ) ;
17831923 const ref = id && api . getPanel ( id ) ? id : null ;
17841924 if ( ref ) paneToCopyByIdRef . current . set ( newId , ref ) ;
1785- freshlySpawnedRef . current . add ( newId ) ;
1925+ // Horizontal split places the new pane to the right → reveal from its left edge.
1926+ // Vertical split places it below → reveal from its top edge.
1927+ freshlySpawnedRef . current . set ( newId , direction === 'right' ? 'left' : 'top' ) ;
17861928 api . addPanel ( {
17871929 id : newId ,
17881930 component : 'terminal' ,
0 commit comments