Dashboard: open apps maximized in-page, with minimize and single-instance behavior (#3419)

* 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/<name> 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).
This commit is contained in:
Nariman Jelveh
2026-07-21 17:41:45 -07:00
committed by GitHub
parent c706b4472d
commit bf545dda25
4 changed files with 487 additions and 25 deletions
+62 -20
View File
@@ -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 = '<div class="dashboard-tab-content myapps-tab">';
@@ -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/<name> 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
+4
View File
@@ -65,6 +65,10 @@ async function UIDashboard (options) {
// eslint-disable-next-line no-unused-vars
options = options ?? {};
// Mark dashboard mode on <body> 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];
+404 -1
View File
@@ -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';
+17 -4
View File
@@ -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;
}