From bf545dda251a8858d5abaeafd06f4d7e8f7c7477 Mon Sep 17 00:00:00 2001 From: Nariman Jelveh Date: Tue, 21 Jul 2026 17:41:45 -0700 Subject: [PATCH] Dashboard: open apps maximized in-page, with minimize and single-instance behavior (#3419) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * feat(gui): open dashboard apps maximized in-page with minimize + single instance Apps launched from the dashboard's Apps tab now open as maximized Puter windows in the same page (via launch_app) instead of a new browser tab. External website shortcuts still open in a new tab since arbitrary sites can't reliably be iframed. Dashboard app windows keep their minimize button (fullpage mode normally hides it since there's no taskbar). With no taskbar item to animate toward, hideWindow now hides the window in place and flags it with data-minimized_in_place; showWindow un-hides it via that flag. An explicit flag is used because data-orig-* can't distinguish the two minimize paths - drag/maximize handlers set those attrs too. Tiles are single-instance: clicking a tile un-hides a minimized instance or focuses a visible one instead of launching a duplicate, and launches in flight swallow repeat clicks. Also fixes .window-body-app height in dashboard mode: fullpage mode sizes it to 100% assuming no titlebar, which clipped the bottom 29px of every app. * feat(gui): iOS-style minimize-to-icon morph in dashboard mode Minimizing a window in dashboard mode now zooms it into its app's tile on the Apps tab — but only when the user can see that tile (Apps section active AND the tile on the pager page currently in view); otherwise the existing hide-in-place fade is kept, as on phones and with prefers-reduced-motion. The animation is a two-layer morph on one 450ms cubic-bezier(0.32, 0.72, 0, 1) path: the live window scales onto the tile's icon box (transform + opacity only — no per-frame iframe reflow) while its corner radius grows toward the icon squircle, and an enlarged clone of the icon flies congruently with it, cross-fading in as the window fades out and landing exactly on the real icon's slot (hidden until the handoff), so the window reads as becoming the icon. Window geometry is never touched: only inline transform/opacity/radius are set and restored byte-identical when the animation ends, and showWindow()'s existing minimized-in-place path restores as before — including a reopen mid-animation. * fix(gui): keep the minimize-morph ghost icon square (contain-fit) The icon clone previously mapped rect-onto-rect over the window, which stretched the square icon anamorphically. A covering square was tried and jittered: it protruded past the card's short edge and the halo popped in mid-crossfade. Contain-fit resolves both: the ghost scales uniformly (always square), sized to the window's short edge and centered — its side tracks the card's short edge exactly for the whole flight (both interpolate linearly between the same endpoints on the same curve), so the card's rectangular flanks melt away around a steady, undistorted icon. * feat(gui): add 'Open in new tab' to app tile context menu Every tile's right-click menu now leads with Open in new tab, which opens the app the way tiles did before in-page windows: /app/ in a new browser tab (noopener), or the site link for external tiles. Non-uninstallable apps previously had no menu at all; they now get this single item, and uninstallable ones show it above a divider + Uninstall. * feat(gui): reverse morph — dashboard tile icons zoom into their opening window Opening an app in dashboard mode now runs the minimize morph backwards: the window grows out of its tile's icon box (transform + opacity only, radius relaxing from the 22% squircle to the resting value) while a clone of the icon enlarges IN PLACE on its slot — capped at 2.5x, fully dissolved by ~250% growth — as the window fades in underneath on the same 450ms cubic-bezier(0.32, 0.72, 0, 1) path with the crossfade handoff mirrored (window in at 80-240ms, ghost out at 120-320ms). Both open paths get the morph, with the same visibility conditions as minimize (Apps section active, tile on the pager page in view, not phone, no reduced motion), falling back to the existing fades otherwise: - fresh launch: TabApps passes window_options.morph_from_dashboard_tile, and UIWindow morphs instead of the 70ms opening fade. Since .window is display:none from the stylesheet until later in the pipeline, the hook shows it before measuring (nothing has painted yet, so neither the early show nor the hide-back on a declined morph can flash). - un-minimize: showWindow's minimized-in-place branch morphs instead of fadeIn(150), keeping the z-index bump and 80ms focus timing. A data-window_morphing flag guards against opposing morphs fighting over the window's inline styles: minimize clicked mid-open-zoom (or a tile click mid-minimize-zoom) falls back to the pre-morph fade behavior, and each animation still restores every inline property byte-identical when it ends. * Update dashboard.css * feat(gui): cap the open-morph ghost icon at 2x, dissolving in place The tile icon no longer flies toward the window center nor grows to full contain-fit (10x+ reads as a giant blurry sticker): it enlarges in place on its own slot and its growth is capped so the fade-out completes right around 200% of its size — the window alone carries the motion and the rest of the growth. * fix(gui): drop the tile :active press-shrink that stuttered the open morph Clicking a tile launches the app and the open morph grows the icon in place — the 0.95 press-shrink made every launch read as shrink-then-grow. The icon now holds its hover scale through the click and enlarges straight from there (the morph ghost is measured from the rendered rect, so it picks up the hovered size seamlessly). --- src/gui/src/UI/Dashboard/TabApps.js | 82 +++-- src/gui/src/UI/Dashboard/UIDashboard.js | 4 + src/gui/src/UI/UIWindow.js | 405 +++++++++++++++++++++++- src/gui/src/css/dashboard.css | 21 +- 4 files changed, 487 insertions(+), 25 deletions(-) diff --git a/src/gui/src/UI/Dashboard/TabApps.js b/src/gui/src/UI/Dashboard/TabApps.js index fe82c639a..ea3c9041a 100644 --- a/src/gui/src/UI/Dashboard/TabApps.js +++ b/src/gui/src/UI/Dashboard/TabApps.js @@ -1,5 +1,6 @@ import UIContextMenu from '../UIContextMenu.js'; import UIAlert from '../UIAlert.js'; +import launch_app from '../../helpers/launch_app.js'; import { isTouchPrimaryDevice } from './ContextMenu/ContextMenu.js'; import { reconcileAppOrder, serializeAppOrder, mergeSavedOrder, APPS_ORDER_KV_KEY } from './appOrder.js'; @@ -313,6 +314,7 @@ const TabApps = { _pendingLoad: null, _savedOrderNames: null, _orderSavedAtSeq: 0, + _launchingApps: new Set(), html () { let h = '
'; @@ -349,8 +351,9 @@ const TabApps = { }); // Handle app tile clicks. External apps carry a target link (their - // index_url) and open the app's website directly; everything else - // opens the Puter app page — matching the Home tab. + // index_url) and open the app's website directly in a new browser tab + // (an external site can't be reliably iframed); everything else + // launches the app as a maximized window in this same page. $el_window.on('click', '.myapps-tile', function (e) { e.preventDefault(); e.stopPropagation(); @@ -364,7 +367,34 @@ const TabApps = { if ( targetLink && targetLink !== '' ) { window.open(targetLink, '_blank', 'noopener,noreferrer'); } else if ( appName ) { - window.open(`/app/${appName}`, '_blank', 'noopener,noreferrer'); + // One instance per app when launched from here: un-hide a + // minimized instance / focus a visible one rather than + // launching a duplicate. + const $existing = $(`.window[data-app="${html_encode(appName)}"]`); + if ( $existing.length ) { + const $win = $existing.last(); + const minimized = $win.attr('data-is_minimized'); + if ( minimized === '1' || minimized === 'true' ) { + $win.showWindow(); + } else { + $win.focusWindow(); + } + return; + } + // A second click while the first launch's fetches are still in + // flight has no window to find yet — swallow it instead of + // spawning a duplicate instance. + if ( self._launchingApps.has(appName) ) return; + self._launchingApps.add(appName); + // morph_from_dashboard_tile: the new window opens by + // morphing this tile's icon into it (see UIWindow) instead + // of the plain opening fade. + launch_app({ + name: appName, + maximized: true, + window_options: { morph_from_dashboard_tile: true }, + }) + .finally(() => self._launchingApps.delete(appName)); } }); @@ -385,26 +415,38 @@ const TabApps = { const appName = $(this).attr('data-app-name'); const appTitle = $(this).attr('data-app-title'); const appUid = $(this).attr('data-app-uid'); + const targetLink = $(this).attr('data-target-link'); const noUninstall = APP_NAMES_NO_UNINSTALL.has((appName || '').toLowerCase()); - const items = noUninstall - ? [] - : [ - { - html: 'Uninstall', - onClick: () => { - showUninstallModal({ - appName, - appTitle, - appUid, - self, - $el_window, - }); - }, + // Every app opens in a new browser tab the way tiles did before + // in-page windows: external tiles via their site link, everything + // else via its /app/ URL. + const items = [ + { + html: 'Open in new tab', + onClick: () => { + if ( targetLink && targetLink !== '' ) { + window.open(targetLink, '_blank', 'noopener,noreferrer'); + } else if ( appName ) { + window.open(`/app/${encodeURIComponent(appName)}`, '_blank', 'noopener,noreferrer'); + } }, - ]; - - if ( items.length === 0 ) return; + }, + ]; + if ( ! noUninstall ) { + items.push('-', { + html: 'Uninstall', + onClick: () => { + showUninstallModal({ + appName, + appTitle, + appUid, + self, + $el_window, + }); + }, + }); + } // A touch long-press arms a drag pickup (see _onTilePointerDown). If // the user held rather than dragged, they want this menu — cancel the diff --git a/src/gui/src/UI/Dashboard/UIDashboard.js b/src/gui/src/UI/Dashboard/UIDashboard.js index e5655bc88..65ff6a3cf 100644 --- a/src/gui/src/UI/Dashboard/UIDashboard.js +++ b/src/gui/src/UI/Dashboard/UIDashboard.js @@ -65,6 +65,10 @@ async function UIDashboard (options) { // eslint-disable-next-line no-unused-vars options = options ?? {}; + // Mark dashboard mode on so window chrome can adapt — e.g. app + // windows keep their minimize button even though fullpage-mode hides it. + $('body').addClass('dashboard-mode'); + // Create mutable tabs array from built-in tabs const tabs = [...builtinTabs]; diff --git a/src/gui/src/UI/UIWindow.js b/src/gui/src/UI/UIWindow.js index 4978f2892..4cf700cc2 100644 --- a/src/gui/src/UI/UIWindow.js +++ b/src/gui/src/UI/UIWindow.js @@ -654,7 +654,32 @@ async function UIWindow (options) { $(el_window).focusWindow(); } - if ( window.animate_window_opening ) { + // A launch from a dashboard Apps-tab tile (TabApps passes + // morph_from_dashboard_tile) morphs the tile's icon into the opening + // window — the reverse of hideWindow's minimize morph. The window is + // already appended, displayed, and at its final (maximized) geometry + // here, and nothing has painted since it was appended, so the morph's + // collapsed start state applies without a flash of the full window. + // Falls through to the standard opening fade when the tile isn't + // visible (other tab, other pager page, phone, reduced motion). + let morphed_from_tile = false; + if ( options.morph_from_dashboard_tile && window.animate_window_opening + && options.is_visible && ! options.fadeIn && ! isMobile.phone + && ! (window.matchMedia && window.matchMedia('(prefers-reduced-motion: reduce)').matches) ) { + const tile = dashboard_tile_in_view(options.app); + if ( tile ) { + // .window is display:none from the stylesheet until the show() + // further down; the morph needs the window laid out to measure + // it, so show it now — nothing has painted yet, so neither the + // early show nor the hide-back on failure ever flashes. + const was_hidden = ! $(el_window).is(':visible'); + if ( was_hidden ) $(el_window).show(); + morphed_from_tile = morph_window_from_tile(el_window, tile); + if ( ! morphed_from_tile && was_hidden ) $(el_window).hide(); + } + } + + if ( ! morphed_from_tile && window.animate_window_opening ) { // animate window opening $(el_window).css({ 'opacity': '0', @@ -3821,6 +3846,48 @@ $.fn.showWindow = async function (options) { if ( $(this).hasClass('window') ) { // show window const el_window = this; + + // A window minimized with no taskbar to animate toward (dashboard + // mode) was hidden in place by hideWindow — its geometry was + // never disturbed. If the app's tile is visible on the Apps tab's + // current page, grow the window back out of it (the reverse of + // hideWindow's zoom-to-tile morph); otherwise just fade it back + // in as before. (The explicit flag is used because data-orig-* + // can't distinguish the two minimize paths: drag/maximize + // handlers set it too.) + if ( $(el_window).attr('data-minimized_in_place') === '1' ) { + $(el_window).attr({ + 'data-is_minimized': false, + 'data-minimized_in_place': '0', + }); + $(el_window).css('z-index', ++window.last_window_zindex); + const reduce_motion = window.matchMedia + && window.matchMedia('(prefers-reduced-motion: reduce)').matches; + // Skip the morph while another morph still owns the window's + // inline styles (reopen mid-minimize-zoom): clearing the + // flags above already makes that animation's cleanup leave + // the window visible and restored, same as before. + const tile = (reduce_motion || isMobile.phone + || $(el_window).attr('data-window_morphing') === '1') + ? null + : dashboard_tile_in_view($(el_window).attr('data-app')); + let morphed = false; + if ( tile ) { + // The morph needs the window displayed and laid out to + // measure it; no paint happens between this show() and + // the morph's start state, so nothing flashes. + const was_hidden = ! $(el_window).is(':visible'); + if ( was_hidden ) $(el_window).show(); + morphed = morph_window_from_tile(el_window, tile); + if ( ! morphed && was_hidden ) $(el_window).hide(); + } + if ( ! morphed ) $(el_window).fadeIn(150); + setTimeout(() => { + $(this).focusWindow(); + }, 80); + return; + } + $(el_window).css({ 'transition': 'top 0.2s, left 0.2s, bottom 0.2s, right 0.2s, width 0.2s, height 0.2s', top: `${$(el_window).attr('data-orig-top') }px`, @@ -3956,6 +4023,176 @@ $.fn.focusWindow = function (event) { return this; }; +/** + * The dashboard Apps-tab tile for `app_name`, but only when the user can + * actually see it right now: the dashboard's Apps section must be the active + * tab AND the tile must sit on the pager page currently in view (pages are + * laid side by side in a horizontal scroller, so an off-page tile has a + * rendered box the user can't see). Returns the tile element, or null. + */ +function dashboard_tile_in_view (app_name) { + if ( ! app_name || typeof CSS === 'undefined' || ! CSS.escape ) return null; + const tiles = document.querySelectorAll( + `.dashboard-section-apps.active .myapps-tile[data-app-name="${CSS.escape(app_name)}"]`, + ); + for ( const tile of tiles ) { + const rect = tile.getBoundingClientRect(); + if ( rect.width <= 0 || rect.height <= 0 ) continue; + const scroller = tile.closest('.myapps-pager-scroller'); + const clip = (scroller || tile.parentElement).getBoundingClientRect(); + const cx = rect.left + rect.width / 2; + const cy = rect.top + rect.height / 2; + if ( cx >= clip.left && cx <= clip.right && cy >= clip.top && cy <= clip.bottom ) { + return tile; + } + } + return null; +} + +/** + * The reverse of hideWindow's zoom-to-tile morph: the tile's icon flies out + * and inflates into the window while the window fades in — the same two + * congruent layers on the same 450ms decelerating path, run backwards, so + * opening reads as the icon becoming the window. See hideWindow's morph for + * the mechanics (congruent layers, contain-fit ghost, compositor-only + * animation); this mirrors it constant for constant. + * + * The window must be in the DOM, displayed, and at its final geometry — + * geometry is never touched, and every inline property this sets is restored + * byte-identical when the animation ends. Returns true if the morph started; + * false if either box was unmeasurable (caller falls back to its fade). + */ +function morph_window_from_tile (el_window, tile) { + const icon = tile.querySelector('.myapps-tile-icon') || tile; + const win_rect = el_window.getBoundingClientRect(); + const icon_rect = icon.getBoundingClientRect(); + if ( win_rect.width <= 0 || win_rect.height <= 0 + || icon_rect.width <= 0 || icon_rect.height <= 0 ) { + return false; + } + + const $win = $(el_window); + // Everything touched is restored from these exact inline values once + // the animation ends. + const orig_style = { + transform: el_window.style.transform, + transformOrigin: el_window.style.transformOrigin, + opacity: el_window.style.opacity, + borderRadius: el_window.style.borderRadius, + overflow: el_window.style.overflow, + willChange: el_window.style.willChange, + pointerEvents: el_window.style.pointerEvents, + transition: el_window.style.transition, + }; + const icon_orig_visibility = icon.style.visibility; + // Unlike the minimize morph (whose end states are inline values it + // sets itself), this direction ends on the window's RESTING look, which + // lives in the stylesheet — transitions need explicit end values, so + // read the computed radius up front (the inline value goes back at the + // end either way). + const end_radius = getComputedStyle(el_window).borderRadius; + const end_transform = orig_style.transform || 'none'; + + // Same mapping as the minimize morph, driven backwards: the window + // starts collapsed onto the icon's box... + const scale_x = icon_rect.width / win_rect.width; + const scale_y = icon_rect.height / win_rect.height; + const dx = icon_rect.left - win_rect.left; + const dy = icon_rect.top - win_rect.top; + + // ...and the ghost simply enlarges IN PLACE on the real icon's slot, + // dissolving as the window grows out from underneath it. Unlike the + // minimize morph's contain-fit flight (where the huge ghost only + // becomes visible once it has shrunk near icon size), this ghost is + // visible from frame one — flown toward the window it visibly drifts + // away from its slot, and grown to full contain-fit (easily 10x+) it + // reads as a giant blurry sticker. So it stays put and its growth is + // capped: it fades out completely by ~97% of the decelerating path, + // right around 200% of its size, and the window alone carries the + // motion and the rest of the growth. + const ghost = icon.cloneNode(true); + ghost.style.position = 'fixed'; + ghost.style.left = `${icon_rect.left}px`; + ghost.style.top = `${icon_rect.top}px`; + ghost.style.width = `${icon_rect.width}px`; + ghost.style.height = `${icon_rect.height}px`; + ghost.style.margin = '0'; + // +2, not +1: showWindow's delayed focusWindow() bumps the window's + // z-index once more mid-flight, and the ghost must stay above it. + ghost.style.zIndex = (parseInt($win.css('z-index'), 10) || 1) + 2; + ghost.style.pointerEvents = 'none'; + // 'center' so the scale below enlarges the ghost in place around its + // own slot (the minimize morph's ghost uses 'top left' because it + // translates as it flies; this one never moves). + ghost.style.transformOrigin = 'center'; + ghost.style.willChange = 'transform, opacity'; + ghost.style.opacity = '1'; + ghost.style.transform = 'none'; + const ghost_scale = Math.min( + 2, + win_rect.width / icon_rect.width, + win_rect.height / icon_rect.height, + ); + document.body.appendChild(ghost); + icon.style.visibility = 'hidden'; + + $win.attr('data-window_morphing', '1'); + el_window.style.transition = 'none'; + el_window.style.transformOrigin = 'top left'; + el_window.style.overflow = 'hidden'; // let the radius clip the content + el_window.style.willChange = 'transform, opacity'; + el_window.style.pointerEvents = 'none'; + el_window.style.transform = `translate(${dx}px, ${dy}px) scale(${scale_x}, ${scale_y})`; + // Percentage radius stays proportional under the scale — ~22% is the + // icon-squircle look. + el_window.style.borderRadius = '22%'; + el_window.style.opacity = '0'; + // Flush the starting state (both layers) so the transitions animate + // from it rather than snapping. + void el_window.offsetWidth; + + // The minimize morph's handoff, mirrored: the incoming layer (here the + // window) fades in at 80→240ms, the outgoing ghost lingers to 120→320ms, + // and both ride the same decelerating path — so there is never a hole + // and the swap is invisible: the icon "becomes" the window. + el_window.style.transition = [ + 'transform 0.45s cubic-bezier(0.32, 0.72, 0, 1)', + 'border-radius 0.45s cubic-bezier(0.32, 0.72, 0, 1)', + 'opacity 0.16s ease-in 0.08s', + ].join(', '); + el_window.style.transform = end_transform; + el_window.style.borderRadius = end_radius; + el_window.style.opacity = '1'; + ghost.style.transition = [ + 'transform 0.45s cubic-bezier(0.32, 0.72, 0, 1)', + 'opacity 0.2s ease-out 0.12s', + ].join(', '); + ghost.style.transform = `scale(${ghost_scale})`; + ghost.style.opacity = '0'; + + setTimeout(() => { + // End of the zoom: the ghost has dissolved over the window's + // center — drop it, un-hide the real icon, and put every touched + // inline property back (the end states equal the resting computed + // values, so nothing visibly changes). + ghost.remove(); + icon.style.visibility = icon_orig_visibility; + $win.attr('data-window_morphing', '0'); + el_window.style.transition = 'none'; + el_window.style.transform = orig_style.transform; + el_window.style.transformOrigin = orig_style.transformOrigin; + el_window.style.opacity = orig_style.opacity; + el_window.style.borderRadius = orig_style.borderRadius; + el_window.style.overflow = orig_style.overflow; + el_window.style.willChange = orig_style.willChange; + el_window.style.pointerEvents = orig_style.pointerEvents; + setTimeout(() => { + el_window.style.transition = orig_style.transition; + }, 50); + }, 480); + return true; +} + // hides a window $.fn.hideWindow = async function (options) { $(this).each(async function () { @@ -3963,6 +4200,172 @@ $.fn.hideWindow = async function (options) { // get taskbar item location let taskbar_item_pos = $(`.taskbar .taskbar-item[data-app="${$(this).attr('data-app')}"]`).position(); + // No taskbar item to animate toward (e.g. dashboard mode, which + // has no taskbar): if the app's tile is visible on the Apps tab's + // current page, shrink the window into it so the user sees where + // the app went; otherwise simply hide the window in place. Either + // way the geometry is untouched afterwards and showWindow() + // un-hides via the data-minimized_in_place flag. + if ( ! taskbar_item_pos ) { + const el_window = this; + const $win = $(this); + const reduce_motion = window.matchMedia + && window.matchMedia('(prefers-reduced-motion: reduce)').matches; + // (A morph already in flight — either direction — owns the + // window's inline transform/opacity and will restore them at + // its end, so don't start another on top; fall back to the + // plain fade below, exactly as before the morphs existed.) + const tile = (reduce_motion || isMobile.phone + || $win.attr('data-window_morphing') === '1') + ? null + : dashboard_tile_in_view($win.attr('data-app')); + + if ( tile ) { + // iOS-style zoom-to-icon morph, two congruent layers on + // the same 450ms decelerating path so it reads as ONE + // object transforming: + // 1. the LIVE window scales onto the tile's icon box, + // its corner radius growing toward the squircle, + // fading OUT once it has mostly shrunk; + // 2. an enlarged clone of the icon ("ghost") starts + // covering the window and flies/shrinks with it, + // fading IN as the window fades out, landing exactly + // on the real icon's slot (the real icon is hidden + // until the ghost lands, so nothing doubles). + // Transform + opacity animate on the compositor (no + // per-frame reflow of the app's iframe); the window's + // geometry (top/left/width/height) is never touched, so + // restore is trivial. + const icon = tile.querySelector('.myapps-tile-icon') || tile; + const win_rect = el_window.getBoundingClientRect(); + const icon_rect = icon.getBoundingClientRect(); + if ( win_rect.width > 0 && win_rect.height > 0 + && icon_rect.width > 0 && icon_rect.height > 0 ) { + // Everything touched is restored from these exact + // inline values once the animation ends. + const orig_style = { + transform: el_window.style.transform, + transformOrigin: el_window.style.transformOrigin, + opacity: el_window.style.opacity, + borderRadius: el_window.style.borderRadius, + overflow: el_window.style.overflow, + willChange: el_window.style.willChange, + pointerEvents: el_window.style.pointerEvents, + transition: el_window.style.transition, + }; + const icon_orig_visibility = icon.style.visibility; + // Map the window rect exactly onto the icon rect (and + // the ghost the exact inverse: icon rect onto window + // rect), so the two layers stay congruent throughout. + const scale_x = icon_rect.width / win_rect.width; + const scale_y = icon_rect.height / win_rect.height; + const dx = icon_rect.left - win_rect.left; + const dy = icon_rect.top - win_rect.top; + + const ghost = icon.cloneNode(true); + ghost.style.position = 'fixed'; + ghost.style.left = `${icon_rect.left}px`; + ghost.style.top = `${icon_rect.top}px`; + ghost.style.width = `${icon_rect.width}px`; + ghost.style.height = `${icon_rect.height}px`; + ghost.style.margin = '0'; + ghost.style.zIndex = (parseInt($win.css('z-index'), 10) || 1) + 1; + ghost.style.pointerEvents = 'none'; + ghost.style.transformOrigin = 'top left'; + ghost.style.willChange = 'transform, opacity'; + ghost.style.opacity = '0'; + // UNIFORM start scale so the icon stays square (a + // rect-onto-rect mapping stretches it), sized to fit + // INSIDE the window (contain, centered) — a covering + // square would protrude past the card's short edge + // and the protruding halo pops in mid-crossfade + // (visible jitter). Contained, the ghost's side + // tracks the card's short edge exactly for the whole + // flight (both interpolate linearly between the same + // start and end sizes), so the card's rectangular + // flanks simply melt away around a steady square. + const ghost_scale = Math.min( + win_rect.width / icon_rect.width, + win_rect.height / icon_rect.height, + ); + const ghost_left = win_rect.left + (win_rect.width - icon_rect.width * ghost_scale) / 2; + const ghost_top = win_rect.top + (win_rect.height - icon_rect.height * ghost_scale) / 2; + ghost.style.transform = `translate(${ghost_left - icon_rect.left}px, ${ghost_top - icon_rect.top}px) scale(${ghost_scale})`; + document.body.appendChild(ghost); + icon.style.visibility = 'hidden'; + + $win.attr({ + 'data-is_minimized': true, + 'data-minimized_in_place': '1', + 'data-window_morphing': '1', + }); + el_window.style.transformOrigin = 'top left'; + el_window.style.overflow = 'hidden'; // let the radius clip the content + el_window.style.willChange = 'transform, opacity'; + el_window.style.pointerEvents = 'none'; + // Flush the starting state (both layers) so the + // transitions animate from it rather than snapping. + void el_window.offsetWidth; + // The handoff: by ~130ms the decelerating curve has + // done ~80% of the shrink — the ghost fades in just + // before (80→240ms) and the window fades out just + // after (120→320ms). The overlap means there is never + // a hole, and since both ride the same path the swap + // is invisible: the window "becomes" the icon. + el_window.style.transition = [ + 'transform 0.45s cubic-bezier(0.32, 0.72, 0, 1)', + 'border-radius 0.45s cubic-bezier(0.32, 0.72, 0, 1)', + 'opacity 0.2s ease-out 0.12s', + ].join(', '); + el_window.style.transform = `translate(${dx}px, ${dy}px) scale(${scale_x}, ${scale_y})`; + // Percentage radius stays proportional under the + // scale — ~22% is the icon-squircle look. + el_window.style.borderRadius = '22%'; + el_window.style.opacity = '0'; + ghost.style.transition = [ + 'transform 0.45s cubic-bezier(0.32, 0.72, 0, 1)', + 'opacity 0.16s ease-in 0.08s', + ].join(', '); + ghost.style.transform = 'none'; + ghost.style.opacity = '1'; + + setTimeout(() => { + // End of the zoom: the ghost has landed on the + // real icon's slot — swap them back in the same + // frame, hide the window, and put every touched + // inline property back while nothing is visible. + // If the user re-opened the window mid-animation + // (tile click → showWindow cleared the flags), + // restore but leave it visible. + ghost.remove(); + icon.style.visibility = icon_orig_visibility; + $win.attr('data-window_morphing', '0'); + const minimized = $win.attr('data-is_minimized'); + if ( minimized === '1' || minimized === 'true' ) $win.hide(); + el_window.style.transition = 'none'; + el_window.style.transform = orig_style.transform; + el_window.style.transformOrigin = orig_style.transformOrigin; + el_window.style.opacity = orig_style.opacity; + el_window.style.borderRadius = orig_style.borderRadius; + el_window.style.overflow = orig_style.overflow; + el_window.style.willChange = orig_style.willChange; + el_window.style.pointerEvents = orig_style.pointerEvents; + setTimeout(() => { + el_window.style.transition = orig_style.transition; + }, 50); + }, 480); + return; + } + } + + $win.attr({ + 'data-is_minimized': true, + 'data-minimized_in_place': '1', + }); + $win.fadeOut(150); + return; + } + // Calculate animation target based on taskbar position let animationTarget = {}; const taskbarPosition = window.taskbar_position || 'bottom'; diff --git a/src/gui/src/css/dashboard.css b/src/gui/src/css/dashboard.css index ec039bc5c..fca9695f6 100644 --- a/src/gui/src/css/dashboard.css +++ b/src/gui/src/css/dashboard.css @@ -911,7 +911,7 @@ input.myapps-search::-webkit-search-decoration { width: 56px; height: 56px; overflow: hidden; - background: var(--dashboard-card-background); + background: none; transition: transform 0.15s ease, box-shadow 0.15s ease; display: flex; align-items: center; @@ -939,9 +939,8 @@ input.myapps-search::-webkit-search-decoration { } } -.myapps-tile:active .myapps-tile-icon { - transform: scale(0.95); -} +/* No :active press-shrink on tiles: clicking launches the app, whose open + morph grows the icon — a shrink-then-grow stutter reads as jank. */ /* Keyboard navigation: keyboard focus mirrors the mouse hover enlargement. :focus-visible only, so a mouse click doesn't pin the effect after the @@ -4198,3 +4197,17 @@ body.myapps-reordering .myapps-tile { .submenu-arrow { margin-left: auto; } + +/* Dashboard mode runs fullpage (which normally hides the minimize button + because there's no taskbar to restore from), but here a minimized app can + be brought back from its Apps-tab tile — so keep the button available. */ +body.dashboard-mode .window-minimize-btn { + display: inline; +} + +/* Fullpage mode sizes .window-body-app to 100% because fullpage windows have + no titlebar — but dashboard-mode app windows keep their 29px head, so + without this the bottom of every app is clipped by that much. */ +body.dashboard-mode .window-body-app { + height: calc(100% - 29px) !important; +}