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;
+}