From 04b1e1518c57ef82fe863fe912056921a66b5817 Mon Sep 17 00:00:00 2001 From: KarunyaChavan Date: Fri, 4 Sep 2026 13:16:27 +0530 Subject: [PATCH 1/6] feat(window): add minimize and restore dock animations. - Implement WAAPI-driven scale-and-translate minimize flight targeting the owner dock tile - Implement symmetrical reverse restore flight from dock icon back to window rect - Ensure .os-window--minimized suppresses base transitions to prevent post-animation visual bounce/maximize flash - Add `osWindowBaseId` dataset attribute to dock tiles for reliable icon target resolution --- assets/css/window-states.css | 28 +- src/dock.ts | 8 + src/window/index.ts | 497 +++++++++++++++++++++++++++++++++-- 3 files changed, 501 insertions(+), 32 deletions(-) diff --git a/assets/css/window-states.css b/assets/css/window-states.css index d292ce6c8..f7a5a9639 100644 --- a/assets/css/window-states.css +++ b/assets/css/window-states.css @@ -364,16 +364,27 @@ } /* - * Minimized state — fade + scale down. - * Uses opacity + pointer-events instead of display: none so the - * CSS transition can animate the change. The window is invisible - * and non-interactive but stays in the DOM for a smooth restore. + * Minimized state — window is hidden and non-interactive at the dock. + * Uses opacity + pointer-events + transition: none so neither state + * changes nor WAAPI animation teardown trigger accidental transitions. */ .os-window--minimized { opacity: 0; - transform: scale(0.92) translateY(16px); pointer-events: none; - will-change: transform, opacity; + transition: none !important; +} + +/* + * Genie helpers — active only during the WAAPI flight to/from the + * dock. Disable the base `left/top/width/height/transform/opacity` + * transition so the CSS transition doesn't fight the WAAPI + * `element.animate()` that drives the scale-to-dock motion. The + * minimized class is applied AFTER the minimizing flight lands, and + * the restoring class is removed AFTER the restoring flight lands. + */ +.os-window--minimizing, +.os-window--restoring { + transition: none !important; } /* @@ -418,6 +429,11 @@ .os-window--opening { animation-duration: 0.01ms !important; } + .os-window--minimizing, + .os-window--restoring { + animation: none !important; + transition: none !important; + } } /* diff --git a/src/dock.ts b/src/dock.ts index 652e8561a..dfdd97f5e 100644 --- a/src/dock.ts +++ b/src/dock.ts @@ -1264,6 +1264,10 @@ export class Dock { ); tile.className = filteredClasses.join( ' ' ); tile.dataset.systemId = item.id; + // Genie minimize: Window looks for its dock tile via + // `[data-os-window-base-id="…"]` so the animation can fly to + // the correct icon. System tiles are keyed by their own id. + ( tile.dataset as unknown as Record< string, string > ).osWindowBaseId = item.id; // The constellation resolves a flyout from this attribute the // way it resolves a menu one from `data-menu-slug`, and // `dock-peek` stands down on it for the same reason it stands @@ -1425,6 +1429,10 @@ export class Dock { // the peek card never appears even when the native window // is open. const baseId = this.resolveItemBaseId( item ); + // Genie minimize: Window looks for `[data-os-window-base-id="…"]` + // so the animation can fly to the exact icon. Must be set before + // the `DOCK_TILE_ELEMENT` filter in case a decorator wraps the tile. + ( tile.dataset as unknown as Record< string, string > ).osWindowBaseId = baseId; const teardown = attachDockPeek( { tile, item: { diff --git a/src/window/index.ts b/src/window/index.ts index b51d7d03e..246054fcf 100644 --- a/src/window/index.ts +++ b/src/window/index.ts @@ -350,6 +350,18 @@ export class Window { */ private _stateBeforeMinimize: WindowState | null = null; + /** + * In-flight WAAPI handle for the genie minimize flight. + * @internal + */ + private _minimizeAnimation: Animation | null = null; + + /** + * In-flight WAAPI handle for the reverse genie restore flight. + * @internal + */ + private _restoreAnimation: Animation | null = null; + /** * External-link sub-tabs keyed by a generated tab id. Each carries * its own iframe, its label, and a cleanup hook for the readiness @@ -1881,31 +1893,128 @@ export class Window { return this.element.classList.contains( 'os-window--focused' ); } - public minimize(): void { - // Re-entering minimize from minimize would clobber the saved - // underlying state, leaking the 'minimized' value into the - // restore target. - if ( this.state === 'minimized' ) { - return; + /** + * Whether the user prefers reduced motion. When true, genie + * flights are skipped and the window toggles via the CSS + * `transition-duration: 0.01ms` fallback instead. + * @internal + */ + private _prefersReducedMotion(): boolean { + try { + return window.matchMedia( '(prefers-reduced-motion: reduce)' ).matches; + } catch { + return false; } - this._stateBeforeMinimize = this.state; - this.state = 'minimized'; - this.element.classList.add( 'os-window--minimized' ); + } + + /** + * Resolve the dock tile that owns this window, if any. Tests + * every id the tile could be keyed under (`id`, `baseId`) and + * every attribute a rail writes (`data-menu-slug`, `data-system-id`, + * `data-nav-id`, `data-system-id` on the primary). Returns the + * *primary* button when present so the rect is the 40×40 icon + * rather than the padded tile wrapper. + * + * Returns `null` when the window has no tile (e.g. a transient + * app/runtime window), when the dock is parked (`display:none` + * → zero rect), or when the tile is offscreen. + * @internal + */ + private _getDockTarget(): HTMLElement | null { + const candidates: string[] = []; + const push = ( v: string | undefined ): void => { + if ( typeof v === 'string' && v !== '' && ! candidates.includes( v ) ) { + candidates.push( v ); + } + }; + push( this.id ); + push( this.config.baseId ); + // Some native windows set baseId === id; still try once. + for ( const id of candidates ) { + let esc = id; + try { + if ( typeof CSS !== 'undefined' && typeof CSS.escape === 'function' ) { + esc = CSS.escape( id ); + } + } catch { + esc = id; + } + const selectors = [ + `.os-dock__item[data-os-window-base-id="${ esc }"] .os-dock__item-primary`, + `.os-dock__item[data-os-window-base-id="${ esc }"]`, + `.os-dock__item[data-menu-slug="${ esc }"] .os-dock__item-primary`, + `.os-dock__item[data-system-id="${ esc }"] .os-dock__item-primary`, + `.os-dock__item[data-nav-id="${ esc }"] .os-dock__item-primary`, + `.os-dock__item[data-menu-slug="${ esc }"]`, + `.os-dock__item[data-system-id="${ esc }"]`, + `.os-dock__item[data-nav-id="${ esc }"]`, + ]; + for ( const sel of selectors ) { + const el = document.querySelector< HTMLElement >( sel ); + if ( el ) { + const rect = el.getBoundingClientRect(); + if ( rect.width > 0 && rect.height > 0 ) { + return el; + } + } + } + } + // System Preferences and other System-menu rows have no dedicated + // tile — their launcher lives inside the System flyout. Target + // the System tile itself so the genie has a correct anchor. + const systemSelectors = [ + '.os-dock__item[data-os-window-base-id="os-system"] .os-dock__item-primary', + '.os-dock__item[data-system-id="os-system"] .os-dock__item-primary', + '.os-dock__item[data-os-window-base-id="os-system"]', + '.os-dock__item[data-system-id="os-system"]', + ]; + if ( + this.id === 'desktop-mode-os-settings' || + this.id === 'os-settings' || + this.config.baseId === 'desktop-mode-os-settings' + ) { + for ( const sel of systemSelectors ) { + const el = document.querySelector< HTMLElement >( sel ); + if ( el ) { + const rect = el.getBoundingClientRect(); + if ( rect.width > 0 && rect.height > 0 ) { + return el; + } + } + } + } + // Fallback: animate to the dock itself so every window gets a + // genie even if its tile is on another desktop or has no tile + // at all (transient windows, hidden nav items). + const dock = + document.querySelector< HTMLElement >( '#os-dock' ) ?? + document.querySelector< HTMLElement >( '.os-dock' ); + if ( dock ) { + const rect = dock.getBoundingClientRect(); + if ( rect.width > 0 && rect.height > 0 ) { + return dock; + } + } + return null; + } - // After the transition completes, stop the hidden window doing - // rendering work. `opacity: 0` alone leaves the subtree in the - // render tree: the iframe keeps compositing and its rAF loops - // keep firing, and with several minimized wp-admin pages that's - // real background cost. `visibility: hidden` on the iframe plus - // `content-visibility: hidden` on the window root skip paint, - // layout, and in-iframe rAF entirely while preserving all DOM / - // iframe state for an instant restore. (Timers and Heartbeat - // inside the iframe still run — stopping those would require - // unloading the page.) Browsers without content-visibility - // ignore the property and keep today's behavior. - this.element.addEventListener( 'transitionend', ( e: TransitionEvent ) => { + /** + * After the minimize transition / animation completes, stop the + * hidden window doing rendering work. `opacity: 0` alone leaves the + * subtree in the render tree: the iframe keeps compositing and its + * rAF loops keep firing, and with several minimized wp-admin pages + * that's real background cost. `visibility: hidden` on the iframe + * plus `content-visibility: hidden` on the window root skip paint, + * layout, and in-iframe rAF entirely while preserving all DOM / + * iframe state for an instant restore. (Timers and Heartbeat + * inside the iframe still run — stopping those would require + * unloading the page.) Browsers without content-visibility ignore + * the property and keep today's behavior. + * @internal + */ + private _installMinimizeContentVisibilityGuard(): void { + const applyHidden = (): void => { if ( - e.propertyName === 'opacity' && this.state === 'minimized' && ! this.element.classList.contains( 'os-window--overview' ) ) { @@ -1914,7 +2023,166 @@ export class Window { } this.element.style.setProperty( 'content-visibility', 'hidden' ); } - }, { once: true } ); + }; + applyHidden(); + this.element.addEventListener( + 'transitionend', + ( e: TransitionEvent ) => { + if ( e.propertyName === 'opacity' ) { + applyHidden(); + } + }, + { once: true }, + ); + } + + public minimize(): void { + // Re-entering minimize from minimize would clobber the saved + // underlying state, leaking the 'minimized' value into the + // restore target. + if ( this.state === 'minimized' ) { + return; + } + // Cancel a restore flight that is still in the air — the user + // hit minimize again before the restore genie landed. + if ( this._restoreAnimation ) { + try { + this._restoreAnimation.cancel(); + } catch { + /* ignore */ + } + this._restoreAnimation = null; + this.element.classList.remove( 'os-window--restoring' ); + this.element.style.removeProperty( 'will-change' ); + this.element.style.removeProperty( 'transform' ); + this.element.style.removeProperty( 'opacity' ); + } + if ( this._minimizeAnimation ) { + try { + this._minimizeAnimation.cancel(); + } catch { + /* ignore */ + } + this._minimizeAnimation = null; + this.element.classList.remove( 'os-window--minimizing' ); + } + + this._stateBeforeMinimize = this.state; + + const dockTarget = this._getDockTarget(); + const canAnimate = + !! dockTarget && + ! this._prefersReducedMotion() && + typeof this.element.animate === 'function' && + ! document.hidden && + ! this.element.classList.contains( 'os-window--overview' ); + + if ( canAnimate && dockTarget ) { + const startRect = this.element.getBoundingClientRect(); + const targetRect = dockTarget.getBoundingClientRect(); + const hasGeometry = + startRect.width > 0 && + startRect.height > 0 && + targetRect.width > 0 && + targetRect.height > 0; + if ( hasGeometry ) { + // State flips immediately so the window manager and + // dock indicator see "minimized" during the flight; + // the visual stays alive via WAAPI until we add the + // class on finish. + this.state = 'minimized'; + this.element.classList.add( 'os-window--minimizing' ); + this.element.style.willChange = 'transform'; + + const dx = + ( targetRect.left + targetRect.width / 2 ) - + ( startRect.left + startRect.width / 2 ); + const dy = + ( targetRect.top + targetRect.height / 2 ) - + ( startRect.top + startRect.height / 2 ); + const rawScale = Math.min( + targetRect.width / startRect.width, + targetRect.height / startRect.height, + ); + // 0.22 is the visual ceiling — a window collapsing to + // its dock icon already reads as "gone" at that scale; + // beyond it the flight overshoots and reads as a zoom. + const scale = Math.max( 0.06, Math.min( rawScale, 0.22 ) ); + + let anim: Animation | null = null; + try { + anim = this.element.animate( + [ + { transform: 'translate(0px, 0px) scale(1)', opacity: 1 }, + { + transform: `translate(${ dx }px, ${ dy }px) scale(${ scale })`, + opacity: 1, + }, + ], + { + duration: 380, + easing: 'cubic-bezier(0.32, 0.72, 0, 1)', + fill: 'forwards', + }, + ); + } catch { + anim = null; + } + + if ( anim ) { + this._minimizeAnimation = anim; + const finalize = (): void => { + if ( this._minimizeAnimation !== anim ) { + return; + } + this._minimizeAnimation = null; + this.element.classList.add( 'os-window--minimized' ); + this.element.classList.remove( 'os-window--minimizing' ); + this.element.style.removeProperty( 'will-change' ); + this.element.style.removeProperty( 'transform' ); + this.element.style.removeProperty( 'opacity' ); + try { + anim!.cancel(); + } catch { + /* ignore */ + } + this._installMinimizeContentVisibilityGuard(); + }; + anim.onfinish = finalize; + anim.oncancel = (): void => { + if ( this._minimizeAnimation === anim ) { + this._minimizeAnimation = null; + this.element.style.removeProperty( 'will-change' ); + this.element.style.removeProperty( 'transform' ); + this.element.style.removeProperty( 'opacity' ); + this.element.classList.remove( 'os-window--minimizing' ); + } + }; + + this.onMinimize?.( this ); + this._emitChange( 'state' ); + doAction( HOOKS.WINDOW_MINIMIZED, { + windowId: this.id, + element: this.element, + } ); + if ( this._stateBeforeMinimize === 'fullscreen' ) { + updateFullscreenBodyClass(); + } + return; + } + + // WAAPI threw or returned null — fall through to the + // CSS transition path. + this.element.classList.remove( 'os-window--minimizing' ); + this.element.style.removeProperty( 'will-change' ); + this.element.style.removeProperty( 'transform' ); + this.element.style.removeProperty( 'opacity' ); + } + } + + this.state = 'minimized'; + this.element.classList.add( 'os-window--minimized' ); + this._installMinimizeContentVisibilityGuard(); this.onMinimize?.( this ); this._emitChange( 'state' ); @@ -1937,14 +2205,171 @@ export class Window { * out of sync with `this.state`. */ public restore(): void { - // Restore renderability before the animation starts — the - // un-minimize transition needs the subtree painting again. + // Cancel an in-flight minimize — the user hit restore before + // the genie landed. + if ( this._minimizeAnimation ) { + try { + this._minimizeAnimation.cancel(); + } catch { + /* ignore */ + } + this._minimizeAnimation = null; + this.element.classList.remove( 'os-window--minimizing' ); + this.element.style.removeProperty( 'will-change' ); + this.element.style.removeProperty( 'transform' ); + this.element.style.removeProperty( 'opacity' ); + } + if ( this._restoreAnimation ) { + try { + this._restoreAnimation.cancel(); + } catch { + /* ignore */ + } + this._restoreAnimation = null; + this.element.classList.remove( 'os-window--restoring' ); + this.element.style.removeProperty( 'will-change' ); + this.element.style.removeProperty( 'transform' ); + this.element.style.removeProperty( 'opacity' ); + } + + const wasMinimized = this.state === 'minimized'; + const dockTarget = wasMinimized ? this._getDockTarget() : null; + const canAnimate = + wasMinimized && + !! dockTarget && + ! this._prefersReducedMotion() && + typeof this.element.animate === 'function' && + ! document.hidden && + ! this.element.classList.contains( 'os-window--overview' ); + + if ( canAnimate && dockTarget ) { + // Make the subtree paintable again before we measure its + // natural rect — `content-visibility: hidden` would give a + // zero rect otherwise. + this.element.style.removeProperty( 'content-visibility' ); + if ( this.iframe ) { + this.iframe.style.visibility = ''; + } + // Flip state immediately so the manager, dock indicator, + // and fullscreen body class see the restored value during + // the flight; the visual is still at the dock via WAAPI. + this.element.classList.remove( 'os-window--minimized' ); + this.element.classList.add( 'os-window--restoring' ); + this.element.style.willChange = 'transform'; + + const nextState = this._stateBeforeMinimize ?? 'normal'; + this.state = nextState; + this._stateBeforeMinimize = null; + if ( nextState === 'fullscreen' ) { + updateFullscreenBodyClass(); + this.updateFocusButtonState(); + } + + // Natural rect at the restored position/scale. + const endRect = this.element.getBoundingClientRect(); + const targetRect = dockTarget.getBoundingClientRect(); + const hasGeometry = + endRect.width > 0 && + endRect.height > 0 && + targetRect.width > 0 && + targetRect.height > 0; + + if ( hasGeometry ) { + const dx = + ( targetRect.left + targetRect.width / 2 ) - + ( endRect.left + endRect.width / 2 ); + const dy = + ( targetRect.top + targetRect.height / 2 ) - + ( endRect.top + endRect.height / 2 ); + const rawScale = Math.min( + targetRect.width / endRect.width, + targetRect.height / endRect.height, + ); + const scale = Math.max( 0.06, Math.min( rawScale, 0.24 ) ); + + let anim: Animation | null = null; + try { + anim = this.element.animate( + [ + { + transform: `translate(${ dx }px, ${ dy }px) scale(${ scale })`, + opacity: 1, + }, + { transform: 'translate(0px, 0px) scale(1)', opacity: 1 }, + ], + { + duration: 380, + easing: 'cubic-bezier(0.32, 0.72, 0, 1)', + fill: 'forwards', + }, + ); + } catch { + anim = null; + } + + if ( anim ) { + this._restoreAnimation = anim; + const cleanup = (): void => { + if ( this._restoreAnimation !== anim ) { + return; + } + this._restoreAnimation = null; + this.element.classList.remove( 'os-window--restoring' ); + this.element.style.removeProperty( 'will-change' ); + this.element.style.removeProperty( 'transform' ); + this.element.style.removeProperty( 'opacity' ); + try { + anim!.cancel(); + } catch { + /* ignore */ + } + }; + anim.onfinish = cleanup; + anim.oncancel = (): void => { + if ( this._restoreAnimation === anim ) { + this._restoreAnimation = null; + this.element.style.removeProperty( 'will-change' ); + this.element.style.removeProperty( 'transform' ); + this.element.style.removeProperty( 'opacity' ); + this.element.classList.remove( 'os-window--restoring' ); + } + }; + + this.onRestore?.( this ); + this.onFocusRequest?.( this ); + this._emitChange( 'state' ); + doAction( HOOKS.WINDOW_RESTORED, { + windowId: this.id, + element: this.element, + } ); + return; + } + } + + // WAAPI threw or geometry was degenerate — clean up and + // fall through to the instant path. + this.element.classList.remove( 'os-window--restoring' ); + this.element.style.removeProperty( 'will-change' ); + this.element.style.removeProperty( 'transform' ); + this.element.style.removeProperty( 'opacity' ); + // State already flipped above; still need to fire the + // focus/restore hooks for the non-animated case. + this.onRestore?.( this ); + this.onFocusRequest?.( this ); + this._emitChange( 'state' ); + doAction( HOOKS.WINDOW_RESTORED, { + windowId: this.id, + element: this.element, + } ); + return; + } + + // Instant fallback — no dock target or reduced-motion. this.element.style.removeProperty( 'content-visibility' ); if ( this.iframe ) { this.iframe.style.visibility = ''; } - const wasMinimized = this.state === 'minimized'; this.element.classList.remove( 'os-window--minimized' ); if ( wasMinimized ) { // `null` fallback covers windows whose state was already @@ -4074,6 +4499,26 @@ export class Window { ); this._onCloseTransitionEnd = null; } + if ( this._minimizeAnimation ) { + try { + this._minimizeAnimation.cancel(); + } catch { + /* ignore */ + } + this._minimizeAnimation = null; + } + if ( this._restoreAnimation ) { + try { + this._restoreAnimation.cancel(); + } catch { + /* ignore */ + } + this._restoreAnimation = null; + } + this.element.classList.remove( 'os-window--minimizing', 'os-window--restoring' ); + this.element.style.removeProperty( 'will-change' ); + this.element.style.removeProperty( 'transform' ); + this.element.style.removeProperty( 'opacity' ); // Visible-DOM teardowns deferred from `close()`'s pre-animation // block — run them here, after the closing animation has faded From fecd1262acceea27f500f4037afea782acf3f717 Mon Sep 17 00:00:00 2001 From: KarunyaChavan Date: Fri, 4 Sep 2026 15:01:39 +0530 Subject: [PATCH 2/6] refactor(window): DRY up genie minimize/restore animations - Extract `_clearGenieStyles`, `_cancelGenieAnimation`, `_buildGenieTransform`, and `_canGenieAnimate` helpers. - Eliminate duplicate cancel/cleanup boilerplate across `minimize()`, `restore()`, and `destroy()`. - Unify the geometry math for WAAPI keyframes. --- src/window/index.ts | 236 +++++++++++++++++++------------------------- 1 file changed, 104 insertions(+), 132 deletions(-) diff --git a/src/window/index.ts b/src/window/index.ts index 246054fcf..9c71c3136 100644 --- a/src/window/index.ts +++ b/src/window/index.ts @@ -1998,6 +1998,88 @@ export class Window { return null; } + /** + * Remove the three inline genie-flight styles in one call. + * Called on both normal completion and on cancel/abort. + * @internal + */ + private _clearGenieStyles(): void { + this.element.style.removeProperty( 'will-change' ); + this.element.style.removeProperty( 'transform' ); + this.element.style.removeProperty( 'opacity' ); + } + + /** + * Cancel an in-flight genie animation by name, null its handle, + * and strip the matching flight class. Safe to call when the + * handle is already null — no-op. + * @internal + */ + private _cancelGenieAnimation( which: 'minimize' | 'restore' ): void { + const isMinimize = which === 'minimize'; + const anim = isMinimize ? this._minimizeAnimation : this._restoreAnimation; + if ( ! anim ) { + return; + } + try { + anim.cancel(); + } catch { + /* ignore */ + } + if ( isMinimize ) { + this._minimizeAnimation = null; + this.element.classList.remove( 'os-window--minimizing' ); + } else { + this._restoreAnimation = null; + this.element.classList.remove( 'os-window--restoring' ); + } + this._clearGenieStyles(); + } + + /** + * Compute the `translate + scale` values for a genie flight + * between a window rect and its dock-icon rect. + * + * @param winRect - The window's `getBoundingClientRect()`. + * @param dockRect - The dock tile's `getBoundingClientRect()`. + * @param maxScale - Scale ceiling (0.22 for minimize, 0.24 for restore). + * @return `{ dx, dy, scale }` ready for a WAAPI keyframe. + * @internal + */ + private _buildGenieTransform( + winRect: DOMRect, + dockRect: DOMRect, + maxScale: number, + ): { dx: number; dy: number; scale: number } { + const dx = + ( dockRect.left + dockRect.width / 2 ) - + ( winRect.left + winRect.width / 2 ); + const dy = + ( dockRect.top + dockRect.height / 2 ) - + ( winRect.top + winRect.height / 2 ); + const rawScale = Math.min( + dockRect.width / winRect.width, + dockRect.height / winRect.height, + ); + return { dx, dy, scale: Math.max( 0.06, Math.min( rawScale, maxScale ) ) }; + } + + /** + * Whether the genie animation is eligible right now. + * Returns a type-predicate so callers don't need to re-check + * `dockTarget` after the call. + * @internal + */ + private _canGenieAnimate( dockTarget: HTMLElement | null ): dockTarget is HTMLElement { + return ( + !! dockTarget && + ! this._prefersReducedMotion() && + typeof this.element.animate === 'function' && + ! document.hidden && + ! this.element.classList.contains( 'os-window--overview' ) + ); + } + /** * After the minimize transition / animation completes, stop the * hidden window doing rendering work. `opacity: 0` alone leaves the @@ -2043,41 +2125,15 @@ export class Window { if ( this.state === 'minimized' ) { return; } - // Cancel a restore flight that is still in the air — the user - // hit minimize again before the restore genie landed. - if ( this._restoreAnimation ) { - try { - this._restoreAnimation.cancel(); - } catch { - /* ignore */ - } - this._restoreAnimation = null; - this.element.classList.remove( 'os-window--restoring' ); - this.element.style.removeProperty( 'will-change' ); - this.element.style.removeProperty( 'transform' ); - this.element.style.removeProperty( 'opacity' ); - } - if ( this._minimizeAnimation ) { - try { - this._minimizeAnimation.cancel(); - } catch { - /* ignore */ - } - this._minimizeAnimation = null; - this.element.classList.remove( 'os-window--minimizing' ); - } + + // Cancel any in-flight genie animations before starting a new one. + this._cancelGenieAnimation( 'restore' ); + this._cancelGenieAnimation( 'minimize' ); this._stateBeforeMinimize = this.state; const dockTarget = this._getDockTarget(); - const canAnimate = - !! dockTarget && - ! this._prefersReducedMotion() && - typeof this.element.animate === 'function' && - ! document.hidden && - ! this.element.classList.contains( 'os-window--overview' ); - - if ( canAnimate && dockTarget ) { + if ( this._canGenieAnimate( dockTarget ) ) { const startRect = this.element.getBoundingClientRect(); const targetRect = dockTarget.getBoundingClientRect(); const hasGeometry = @@ -2085,6 +2141,7 @@ export class Window { startRect.height > 0 && targetRect.width > 0 && targetRect.height > 0; + if ( hasGeometry ) { // State flips immediately so the window manager and // dock indicator see "minimized" during the flight; @@ -2094,20 +2151,7 @@ export class Window { this.element.classList.add( 'os-window--minimizing' ); this.element.style.willChange = 'transform'; - const dx = - ( targetRect.left + targetRect.width / 2 ) - - ( startRect.left + startRect.width / 2 ); - const dy = - ( targetRect.top + targetRect.height / 2 ) - - ( startRect.top + startRect.height / 2 ); - const rawScale = Math.min( - targetRect.width / startRect.width, - targetRect.height / startRect.height, - ); - // 0.22 is the visual ceiling — a window collapsing to - // its dock icon already reads as "gone" at that scale; - // beyond it the flight overshoots and reads as a zoom. - const scale = Math.max( 0.06, Math.min( rawScale, 0.22 ) ); + const { dx, dy, scale } = this._buildGenieTransform( startRect, targetRect, 0.22 ); let anim: Animation | null = null; try { @@ -2131,16 +2175,14 @@ export class Window { if ( anim ) { this._minimizeAnimation = anim; - const finalize = (): void => { + anim.onfinish = (): void => { if ( this._minimizeAnimation !== anim ) { return; } this._minimizeAnimation = null; this.element.classList.add( 'os-window--minimized' ); this.element.classList.remove( 'os-window--minimizing' ); - this.element.style.removeProperty( 'will-change' ); - this.element.style.removeProperty( 'transform' ); - this.element.style.removeProperty( 'opacity' ); + this._clearGenieStyles(); try { anim!.cancel(); } catch { @@ -2148,14 +2190,11 @@ export class Window { } this._installMinimizeContentVisibilityGuard(); }; - anim.onfinish = finalize; anim.oncancel = (): void => { if ( this._minimizeAnimation === anim ) { this._minimizeAnimation = null; - this.element.style.removeProperty( 'will-change' ); - this.element.style.removeProperty( 'transform' ); - this.element.style.removeProperty( 'opacity' ); this.element.classList.remove( 'os-window--minimizing' ); + this._clearGenieStyles(); } }; @@ -2171,12 +2210,9 @@ export class Window { return; } - // WAAPI threw or returned null — fall through to the - // CSS transition path. + // WAAPI threw or returned null — fall through to the CSS path. this.element.classList.remove( 'os-window--minimizing' ); - this.element.style.removeProperty( 'will-change' ); - this.element.style.removeProperty( 'transform' ); - this.element.style.removeProperty( 'opacity' ); + this._clearGenieStyles(); } } @@ -2205,44 +2241,14 @@ export class Window { * out of sync with `this.state`. */ public restore(): void { - // Cancel an in-flight minimize — the user hit restore before - // the genie landed. - if ( this._minimizeAnimation ) { - try { - this._minimizeAnimation.cancel(); - } catch { - /* ignore */ - } - this._minimizeAnimation = null; - this.element.classList.remove( 'os-window--minimizing' ); - this.element.style.removeProperty( 'will-change' ); - this.element.style.removeProperty( 'transform' ); - this.element.style.removeProperty( 'opacity' ); - } - if ( this._restoreAnimation ) { - try { - this._restoreAnimation.cancel(); - } catch { - /* ignore */ - } - this._restoreAnimation = null; - this.element.classList.remove( 'os-window--restoring' ); - this.element.style.removeProperty( 'will-change' ); - this.element.style.removeProperty( 'transform' ); - this.element.style.removeProperty( 'opacity' ); - } + // Cancel any in-flight genie animations. + this._cancelGenieAnimation( 'minimize' ); + this._cancelGenieAnimation( 'restore' ); const wasMinimized = this.state === 'minimized'; const dockTarget = wasMinimized ? this._getDockTarget() : null; - const canAnimate = - wasMinimized && - !! dockTarget && - ! this._prefersReducedMotion() && - typeof this.element.animate === 'function' && - ! document.hidden && - ! this.element.classList.contains( 'os-window--overview' ); - if ( canAnimate && dockTarget ) { + if ( wasMinimized && this._canGenieAnimate( dockTarget ) ) { // Make the subtree paintable again before we measure its // natural rect — `content-visibility: hidden` would give a // zero rect otherwise. @@ -2275,17 +2281,7 @@ export class Window { targetRect.height > 0; if ( hasGeometry ) { - const dx = - ( targetRect.left + targetRect.width / 2 ) - - ( endRect.left + endRect.width / 2 ); - const dy = - ( targetRect.top + targetRect.height / 2 ) - - ( endRect.top + endRect.height / 2 ); - const rawScale = Math.min( - targetRect.width / endRect.width, - targetRect.height / endRect.height, - ); - const scale = Math.max( 0.06, Math.min( rawScale, 0.24 ) ); + const { dx, dy, scale } = this._buildGenieTransform( endRect, targetRect, 0.24 ); let anim: Animation | null = null; try { @@ -2315,9 +2311,7 @@ export class Window { } this._restoreAnimation = null; this.element.classList.remove( 'os-window--restoring' ); - this.element.style.removeProperty( 'will-change' ); - this.element.style.removeProperty( 'transform' ); - this.element.style.removeProperty( 'opacity' ); + this._clearGenieStyles(); try { anim!.cancel(); } catch { @@ -2328,10 +2322,8 @@ export class Window { anim.oncancel = (): void => { if ( this._restoreAnimation === anim ) { this._restoreAnimation = null; - this.element.style.removeProperty( 'will-change' ); - this.element.style.removeProperty( 'transform' ); - this.element.style.removeProperty( 'opacity' ); this.element.classList.remove( 'os-window--restoring' ); + this._clearGenieStyles(); } }; @@ -2349,9 +2341,7 @@ export class Window { // WAAPI threw or geometry was degenerate — clean up and // fall through to the instant path. this.element.classList.remove( 'os-window--restoring' ); - this.element.style.removeProperty( 'will-change' ); - this.element.style.removeProperty( 'transform' ); - this.element.style.removeProperty( 'opacity' ); + this._clearGenieStyles(); // State already flipped above; still need to fire the // focus/restore hooks for the non-animated case. this.onRestore?.( this ); @@ -4499,26 +4489,8 @@ export class Window { ); this._onCloseTransitionEnd = null; } - if ( this._minimizeAnimation ) { - try { - this._minimizeAnimation.cancel(); - } catch { - /* ignore */ - } - this._minimizeAnimation = null; - } - if ( this._restoreAnimation ) { - try { - this._restoreAnimation.cancel(); - } catch { - /* ignore */ - } - this._restoreAnimation = null; - } - this.element.classList.remove( 'os-window--minimizing', 'os-window--restoring' ); - this.element.style.removeProperty( 'will-change' ); - this.element.style.removeProperty( 'transform' ); - this.element.style.removeProperty( 'opacity' ); + this._cancelGenieAnimation( 'minimize' ); + this._cancelGenieAnimation( 'restore' ); // Visible-DOM teardowns deferred from `close()`'s pre-animation // block — run them here, after the closing animation has faded From 09a9e7e1c97bd4c094a92bf0537ff956a1f2d4e3 Mon Sep 17 00:00:00 2001 From: KarunyaChavan Date: Fri, 4 Sep 2026 15:19:56 +0530 Subject: [PATCH 3/6] fix(dock): restore minimized windows on dock tile click for shortcut/plugin tiles `openPage()` only called `windowManager.focus()` for windowId-only tiles (shortcut icons promoted to dock, plugin-registered windows), which reorders the z-stack but never unminimizes the window. --- src/dock.ts | 5 +++++ 1 file changed, 5 insertions(+) diff --git a/src/dock.ts b/src/dock.ts index dfdd97f5e..b5abe5740 100644 --- a/src/dock.ts +++ b/src/dock.ts @@ -2410,7 +2410,12 @@ export class Dock { if ( item.windowId && ! item.url ) { const existing = this.windowManager.getById( item.windowId ); if ( existing ) { + // Read before focus() — restore() mutates state to 'normal'. + const wasMinimized = existing.state === 'minimized'; this.windowManager.focus( existing ); + if ( wasMinimized ) { + existing.restore(); + } return; } const wp = ( window as unknown as { From 4fbd229d445872186baec2407c821a2ee2ae0293 Mon Sep 17 00:00:00 2001 From: KarunyaChavan Date: Fri, 4 Sep 2026 17:23:36 +0530 Subject: [PATCH 4/6] feat(window): add vanishing effect for minimize animation. --- src/window/index.ts | 16 +++++++++++++++- 1 file changed, 15 insertions(+), 1 deletion(-) diff --git a/src/window/index.ts b/src/window/index.ts index 9c71c3136..fb7caaa80 100644 --- a/src/window/index.ts +++ b/src/window/index.ts @@ -2158,10 +2158,17 @@ export class Window { anim = this.element.animate( [ { transform: 'translate(0px, 0px) scale(1)', opacity: 1 }, + // Hold opacity through most of the flight so the + // path is readable, then vanish in the final 40%. { - transform: `translate(${ dx }px, ${ dy }px) scale(${ scale })`, + offset: 0.6, + transform: `translate(${ dx * 0.6 }px, ${ dy * 0.6 }px) scale(${ 1 - ( 1 - scale ) * 0.6 })`, opacity: 1, }, + { + transform: `translate(${ dx }px, ${ dy }px) scale(${ scale })`, + opacity: 0, + }, ], { duration: 380, @@ -2287,8 +2294,15 @@ export class Window { try { anim = this.element.animate( [ + // Start invisible at the dock tile, fade in through + // the first 40% — symmetric with the minimize vanish. { transform: `translate(${ dx }px, ${ dy }px) scale(${ scale })`, + opacity: 0, + }, + { + offset: 0.4, + transform: `translate(${ dx * 0.4 }px, ${ dy * 0.4 }px) scale(${ 1 - ( 1 - scale ) * 0.4 })`, opacity: 1, }, { transform: 'translate(0px, 0px) scale(1)', opacity: 1 }, From 46147238b4376947c83c953046c54070e99b88d8 Mon Sep 17 00:00:00 2001 From: KarunyaChavan Date: Fri, 4 Sep 2026 18:21:16 +0530 Subject: [PATCH 5/6] test(window): add minimize and restore WAAPI flight animation tests. - Test WAAPI animation trigger and modifier classes during minimize/restore flights - Verify prefers-reduced-motion bypasses the flight animation directly to minimized state --- tests/vitest/window-state-transitions.test.ts | 69 ++++++++++++++++++- 1 file changed, 68 insertions(+), 1 deletion(-) diff --git a/tests/vitest/window-state-transitions.test.ts b/tests/vitest/window-state-transitions.test.ts index 24faf808e..fa4b4dddd 100644 --- a/tests/vitest/window-state-transitions.test.ts +++ b/tests/vitest/window-state-transitions.test.ts @@ -12,7 +12,7 @@ * stacks classes, loses the saved floating geometry, or forgets the * pre-minimize state surfaces as a hard failure. */ -import { afterEach, beforeEach, describe, expect, test } from 'vitest'; +import { afterEach, beforeEach, describe, expect, test, vi } from 'vitest'; import { Window } from '../../src/window'; import type { WindowConfig } from '../../src/types'; import { @@ -566,4 +566,71 @@ describe( 'Window — state transitions are mutually exclusive', () => { expect( observed ).toEqual( [ 'maximized', 'maximized' ] ); } ); + + test( 'minimize and restore invoke WAAPI flight animation when dock tile is present', () => { + // WAAPI flight drives the scale-to-dock motion when geometry and + // an animation engine are available. `--minimizing` and `--restoring` + // keep the window interactive/visible during flight before the final + // state class lands. + const dockEl = document.createElement( 'div' ); + dockEl.className = 'os-dock__item'; + dockEl.setAttribute( 'data-os-window-base-id', 'w1' ); + dockEl.getBoundingClientRect = vi.fn( () => ( { + left: 500, + top: 700, + right: 548, + bottom: 748, + width: 48, + height: 48, + x: 500, + y: 700, + toJSON: () => {}, + } ) ); + document.body.appendChild( dockEl ); + + handle.win.element.getBoundingClientRect = vi.fn( () => ( { + left: 40, + top: 60, + right: 840, + bottom: 660, + width: 800, + height: 600, + x: 40, + y: 60, + toJSON: () => {}, + } ) ); + + const animateSpy = vi.fn( () => ( { + onfinish: null, + oncancel: null, + cancel: vi.fn(), + } ) ); + handle.win.element.animate = animateSpy as unknown as typeof handle.win.element.animate; + + handle.win.minimize(); + expect( handle.win.element.classList.contains( 'os-window--minimizing' ) ).toBe( true ); + expect( animateSpy ).toHaveBeenCalledTimes( 1 ); + + handle.win.restore(); + expect( handle.win.element.classList.contains( 'os-window--restoring' ) ).toBe( true ); + expect( animateSpy ).toHaveBeenCalledTimes( 2 ); + + dockEl.remove(); + } ); + + test( 'minimize skips WAAPI animation when prefers-reduced-motion is active', () => { + // Accessibility guard: when reduced motion is requested, genie + // flight must be bypassed so the window minimizes immediately. + const origMatchMedia = window.matchMedia; + window.matchMedia = vi.fn().mockReturnValue( { matches: true } ); + + const animateSpy = vi.fn(); + handle.win.element.animate = animateSpy as unknown as typeof handle.win.element.animate; + + handle.win.minimize(); + expect( handle.win.element.classList.contains( 'os-window--minimized' ) ).toBe( true ); + expect( animateSpy ).not.toHaveBeenCalled(); + + window.matchMedia = origMatchMedia; + } ); } ); From aa4e00145f1e6d59508cdfb2785b40f42545cdd7 Mon Sep 17 00:00:00 2001 From: KarunyaChavan Date: Fri, 4 Sep 2026 18:32:14 +0530 Subject: [PATCH 6/6] feat(window): increase animation duration for minimize and restore flights. --- src/window/index.ts | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/src/window/index.ts b/src/window/index.ts index fb7caaa80..f27ed925a 100644 --- a/src/window/index.ts +++ b/src/window/index.ts @@ -2171,7 +2171,7 @@ export class Window { }, ], { - duration: 380, + duration: 480, easing: 'cubic-bezier(0.32, 0.72, 0, 1)', fill: 'forwards', }, @@ -2308,7 +2308,7 @@ export class Window { { transform: 'translate(0px, 0px) scale(1)', opacity: 1 }, ], { - duration: 380, + duration: 480, easing: 'cubic-bezier(0.32, 0.72, 0, 1)', fill: 'forwards', },