diff --git a/src/gui/src/UI/Dashboard/TabApps.js b/src/gui/src/UI/Dashboard/TabApps.js
index 4657a27ed..31dd08a4e 100644
--- a/src/gui/src/UI/Dashboard/TabApps.js
+++ b/src/gui/src/UI/Dashboard/TabApps.js
@@ -134,6 +134,21 @@ const DEEP_LINK_INTRO_CLICK_BEAT_MS = 300;
// DRAG_FLIP_SETTLE_MS this is an allowance: the scroll's ~450ms plus a
// rest so the landing reads before the tile pops.
const DEEP_LINK_INTRO_FLIP_SETTLE_MS = 620;
+// A landing on an app the dashboard doesn't list yet is what INSTALLS it
+// (opening grants the permission that installedApps reports) — so the grid
+// says so: the app's tile materializes at the tail, held invisible until
+// the intro has travelled to its page, then is INSTALLED before the
+// flourish and the morph grow the window out of it. The arrival plays the
+// install grammar users already know: the slot opens with the icon dim
+// inside it — present but not yet usable — a progress stroke draws around
+// the slot, and on completion the icon springs to full color and size
+// while the label names it (keep in sync with
+// .myapps-tile-install-arriving). It is the one beat that never decays:
+// per-app news that happens at most once per app, not a repeated lesson.
+// See _spliceDeepLinkApp.
+const DEEP_LINK_INSTALL_ARRIVE_MS = 1400;
+const DEEP_LINK_INSTALL_REST_MS = 220;
+
// The intro exists to teach ("windows are inflated tiles; minimize goes
// back to the grid"); once learned it would only be a tax on every
// bookmarked landing. After this many delivered — or deliberately skipped —
@@ -539,6 +554,13 @@ const TabApps = {
_orderSavedAtSeq: 0,
_groupsSavedAtSeq: 0,
_launchingApps: new Set(),
+ // A deep-link install mid-arrival ({ name, $el_window }) — its tile is
+ // parked invisible until the intro's arrival beat (see
+ // _spliceDeepLinkApp); and the session's landing-installed apps, kept
+ // so a refresh fetched before the launch's grant lands can't evict
+ // their tiles (the mirror of _removedLocal).
+ _arriving: null,
+ _pendingInstalls: null,
html () {
let h = '
';
@@ -569,6 +591,10 @@ const TabApps = {
this._reorderMode = false;
this._cancelEmptyPress();
this._closeGroup($el_window, { instant: true });
+ // An arrival is a beat of the OLD window's intro; the fresh DOM
+ // renders its tile plainly visible (the pending-install record, by
+ // contrast, is data and survives re-init).
+ this._arriving = null;
this.loadApps($el_window);
@@ -1068,6 +1094,14 @@ const TabApps = {
this._page = Math.min(Math.floor(anchorIndex / layout.perPage), this._pageCount - 1);
$container.html(buildPagerHtml(items, layout, instant));
+ // A tile mid-arrival (a deep-link landing installing its app — see
+ // _spliceDeepLinkApp) stays parked invisible across re-renders; the
+ // intro's arrival beat, not the render, is what reveals it.
+ if ( this._arriving ) {
+ const el = $container.find('.myapps-tile').toArray()
+ .find(t => t.dataset.appName === this._arriving.name);
+ if ( el ) el.classList.add('myapps-tile-installing');
+ }
revealWhenLoaded($container);
this.updateRunningDots($el_window);
// An open folder outlives the grid rebuilds underneath it (a
@@ -2817,6 +2851,24 @@ const TabApps = {
merged.push(app);
}
+ // Apps this session installed by landing on their URL (see
+ // _spliceDeepLinkApp): a load fetched before the launch's
+ // permission grant landed doesn't list them yet, and applying
+ // it would evict a tile the user just watched arrive. The local
+ // record fills that gap — and retires the moment the server
+ // confirms the app (it is in `seen` already) or the user
+ // uninstalls it again (removedNames).
+ if ( this._pendingInstalls ) {
+ for ( const [name, app] of [...this._pendingInstalls] ) {
+ if ( seen.has(name) || removedNames.has(name) ) {
+ this._pendingInstalls.delete(name);
+ continue;
+ }
+ seen.add(name);
+ merged.push({ ...app });
+ }
+ }
+
// A page beyond the first failed: fill the gap with apps we
// already know about so one flaky request among N can't make
// apps vanish from the grid, from search, or from a subsequently
@@ -2907,6 +2959,97 @@ const TabApps = {
}
},
+ // A deep-link landing on an app the dashboard doesn't list: opening is
+ // what installs it (the launch grants the permission installedApps
+ // reports), so the grid says so NOW rather than on some later refresh —
+ // without this the user's first Back found no tile to minimize into and
+ // a grid without the app they were just using. The app joins _apps at
+ // the tail (where reconcileAppOrder appends new apps) with a tile drawn
+ // from the landing's own app-info prefetch, so only a confirmed-real
+ // app ever materializes, with its real icon. `mark` parks the tile
+ // invisible (.myapps-tile-installing) for the intro's arrival beat to
+ // reveal; unmarked (no-intro paths) it simply appears. The
+ // _pendingInstalls record shields the tile from refreshes fetched
+ // before the grant lands (see _fetchAndRenderApps). The order is NOT
+ // saved: a default-position append must not freeze a custom order the
+ // user never made. Resolves true when the app is on the grid (or
+ // already was), false when there is nothing to add.
+ async _spliceDeepLinkApp ($el_window, appName, appInfoPromise, { mark = false, boundedSleep = null } = {}) {
+ if ( ! appInfoPromise ) return false;
+ let info = null;
+ try {
+ info = boundedSleep
+ ? await Promise.race([appInfoPromise, boundedSleep()])
+ : await appInfoPromise;
+ } catch ( _e ) { /* a failed prefetch: nothing real to add */ }
+ if ( ! info || ! info.name || info.name !== appName ) return false;
+ if ( ! Array.isArray(this._apps) ) return false;
+ if ( this._apps.some(a => a.name === appName) ) return true; // a refresh beat us to it
+
+ const app = {
+ name: info.name,
+ title: info.title || info.name,
+ uid: info.uuid || info.uid || null,
+ index_url: info.index_url || null,
+ external: false,
+ iconUrl: info.icon || null,
+ };
+ if ( ! this._pendingInstalls ) this._pendingInstalls = new Map();
+ this._pendingInstalls.set(appName, app);
+ // A previously uninstalled app is being re-installed by this
+ // landing; the removed record must not go on filtering it.
+ this._setAppRemoved(appName, false);
+ this._apps.push(app);
+ if ( mark ) this._arriving = { name: appName, $el_window };
+ // instant only when the pager is already on screen: this re-render
+ // must not blink a revealed grid back through the load fade, nor
+ // skip the fade of one still arriving.
+ const revealed = $el_window.find('.myapps-pager').length > 0
+ && $el_window.find('.myapps-pager-loading').length === 0;
+ this.renderApps($el_window, { preservePage: true, instant: revealed });
+ return true;
+ },
+
+ // Reveal the tile a deep-link install parked invisible (see
+ // _spliceDeepLinkApp). Animated only from the intro's arrival beat;
+ // instant from every other path — interruption, hidden tab, and
+ // settleDeepLinkLaunch's safety net — because the parked state must
+ // never outlive the intro: an invisible tile is a broken grid.
+ _revealArrivingTile (animate) {
+ const arriving = this._arriving;
+ if ( ! arriving ) return;
+ this._arriving = null;
+ const el = arriving.$el_window.find('.myapps-page .myapps-tile').toArray()
+ .find(t => t.dataset.appName === arriving.name);
+ if ( ! el ) return;
+ if ( animate && ! this._reduceMotion() ) {
+ // The installation (see .myapps-tile-install-arriving): the
+ // slot opens with the icon dim inside it, a progress stroke
+ // draws clockwise around the slot, and on completion the icon
+ // springs to full color while the label names it. Each piece
+ // is a backwards-filled keyframe, so the class swap never
+ // flashes the resting tile. The progress ring is a transient
+ // element rather than a pseudo — the tile's ::before is the
+ // folder well and its ::after the running dot. Everything is
+ // torn down just after the run; a re-render replacing the node
+ // mid-run simply shows the resting tile (the detached nodes are
+ // cleaned up regardless).
+ el.classList.add('myapps-tile-install-arriving');
+ el.classList.remove('myapps-tile-installing');
+ const ring = document.createElement('div');
+ ring.className = 'myapps-install-progress';
+ ring.innerHTML = '';
+ el.appendChild(ring);
+ setTimeout(() => {
+ el.classList.remove('myapps-tile-install-arriving');
+ ring.remove();
+ }, DEEP_LINK_INSTALL_ARRIVE_MS + 80);
+ } else {
+ el.classList.remove('myapps-tile-installing');
+ }
+ },
+
// A direct landing on /app/ (see initgui's dashboard branch) plays
// the same click→morph→open sequence a real tile click does, so the user
// sees WHICH tile the app came from — and where minimize will put it
@@ -2944,11 +3087,20 @@ const TabApps = {
// the tile mid-intro is swallowed instead of spawning a second instance
// (same guard as the click handler). The caller MUST call
// settleDeepLinkLaunch once its launch attempt settles.
- async beginDeepLinkLaunch (appName, $el_window) {
+ async beginDeepLinkLaunch (appName, $el_window, appInfoPromise = null) {
this._launchingApps.add(appName);
// No animations, no intro: the morph and the flourish would both
- // no-op, so waiting on the grid would only delay the launch.
- if ( ! window.animate_window_opening || this._reduceMotion() ) return null;
+ // no-op, so waiting on the grid would only delay the launch. The
+ // landing still installs the app, though — give it its tile
+ // silently once the grid is up (fire-and-forget: nothing here may
+ // hold the launch, and a failure just means the tile waits for the
+ // next refresh, as it always did).
+ if ( ! window.animate_window_opening || this._reduceMotion() ) {
+ Promise.resolve(this.loadApps($el_window))
+ .then(() => this._spliceDeepLinkApp($el_window, appName, appInfoPromise))
+ .catch(() => {});
+ return null;
+ }
const deadline = Date.now() + DEEP_LINK_INTRO_DEADLINE_MS;
// How many intros this account has already been shown — fetched in
@@ -2992,8 +3144,29 @@ const TabApps = {
]);
} catch ( _e ) { /* a failed load leaves _apps unset; handled below */ }
if ( interrupted ) return null;
- if ( ! Array.isArray(this._apps) || ! this._apps.some(a => a.name === appName) ) {
- return null;
+ if ( ! Array.isArray(this._apps) ) return null;
+ if ( ! this._apps.some(a => a.name === appName) ) {
+ // Not on the dashboard: this landing is what installs it.
+ // Materialize its tile (parked invisible for the arrival
+ // beat below), bounded by the same deadline as the rest of
+ // the intro — the prefetch has been in flight since the
+ // landing, so it is normally long resolved.
+ const spliced = await this._spliceDeepLinkApp($el_window, appName, appInfoPromise, {
+ mark: true,
+ boundedSleep: () => sleep(Math.max(0, deadline - Date.now())),
+ });
+ if ( ! spliced ) {
+ // The wait ran out (deadline, or input woke it) with the
+ // prefetch still pending: the intro moves on, but the
+ // data half must not be lost — add the tile plainly
+ // whenever the info lands (a rejected/absent app still
+ // adds nothing).
+ this._spliceDeepLinkApp($el_window, appName, appInfoPromise).catch(() => {});
+ return null;
+ }
+ // Interrupted while splicing: no intro, but the tile stays —
+ // settleDeepLinkLaunch's reveal uncovers it.
+ if ( interrupted ) return null;
}
while ( Date.now() < deadline && ! interrupted ) {
// A hidden page (deep link opened in a background tab) can't
@@ -3053,6 +3226,16 @@ const TabApps = {
await sleep(DEEP_LINK_INTRO_FLIP_SETTLE_MS);
if ( interrupted || document.visibilityState === 'hidden' ) return tile;
}
+ // This landing installed the app: its tile ARRIVES now — after
+ // the travel, so the user watches it materialize where it will
+ // live — and only then does the launch grow out of it. Unlike
+ // the beats this never decays: it is per-app news, delivered at
+ // most once per app, ever.
+ if ( this._arriving && this._arriving.name === appName ) {
+ this._revealArrivingTile(true);
+ await sleep(DEEP_LINK_INSTALL_ARRIVE_MS + DEEP_LINK_INSTALL_REST_MS);
+ if ( interrupted || document.visibilityState === 'hidden' ) return tile;
+ }
// The app lives in a folder: open it, so the launch grows out of
// the icon where the app actually is — and the user learns where
// to find it again. settleDeepLinkLaunch shuts it afterwards.
@@ -3121,6 +3304,11 @@ const TabApps = {
settleDeepLinkLaunch (appName, tile) {
this._launchingApps.delete(appName);
settle_dashboard_tile_launch(tile);
+ // Whatever path the intro took out (interruption, hidden tab, the
+ // deadline), an unrevealed arrival must not outlive it — an
+ // invisible tile is a broken grid. The intro's own reveal already
+ // cleared this on the happy path.
+ this._revealArrivingTile(false);
// A folder the intro opened to show where the app lives has done its
// job once the window is up (or the launch has failed).
if ( this._deepLinkFolder ) {
diff --git a/src/gui/src/css/dashboard.css b/src/gui/src/css/dashboard.css
index c02caf962..0d2c03c2f 100644
--- a/src/gui/src/css/dashboard.css
+++ b/src/gui/src/css/dashboard.css
@@ -1746,6 +1746,108 @@ input.myapps-group-name:focus {
}
}
+/* A tile installed by a deep-link landing, not yet arrived: rendered in
+ place — layout, page count, and the wayfinding flip all see it — but held
+ invisible for the intro's arrival beat to reveal (see _spliceDeepLinkApp /
+ _revealArrivingTile in TabApps.js). */
+.myapps-tile-installing {
+ opacity: 0;
+ transform: scale(0.55);
+ pointer-events: none;
+}
+
+/* The arrival itself: an INSTALLATION, staged over 1400ms (mirrored by
+ DEEP_LINK_INSTALL_ARRIVE_MS in TabApps.js) with the grammar users know
+ from every app store:
+ 1. the slot opens — the folder well's own surface — and the icon
+ materializes into it DIM (grayscale, translucent, slightly small):
+ present, but not yet usable;
+ 2. a progress stroke draws clockwise around the slot (the
+ .myapps-install-progress element _revealArrivingTile injects);
+ 3. the stroke completes and everything resolves at once — the well
+ recedes, the ring dissolves outward, the icon springs to full
+ color and size, and the label names what was installed.
+ Every piece fills backwards so the delayed ones hold their start state
+ through their delay. Only _revealArrivingTile applies the class and
+ injects the ring, never under prefers-reduced-motion. */
+.myapps-tile-install-arriving::before {
+ animation: myapps-install-well 1400ms ease backwards;
+}
+
+.myapps-tile-install-arriving .myapps-tile-icon {
+ animation: myapps-install-icon 1400ms ease backwards;
+}
+
+.myapps-tile-install-arriving .myapps-tile-label {
+ animation: myapps-install-label 320ms ease 1020ms backwards;
+}
+
+/* The progress ring: the same box the folder well draws (see
+ .myapps-tile::before), as a stroke. A transient element, not a pseudo —
+ the tile's ::before is the well and its ::after the running dot. */
+.myapps-install-progress {
+ position: absolute;
+ top: -6px;
+ left: 50%;
+ width: calc(var(--myapps-icon-size, 56px) + 12px);
+ height: calc(var(--myapps-icon-size, 56px) + 12px);
+ margin-left: calc((var(--myapps-icon-size, 56px) + 12px) / -2);
+ pointer-events: none;
+ animation: myapps-install-progress-done 360ms ease 1000ms forwards;
+}
+
+.myapps-install-progress svg {
+ display: block;
+ width: 100%;
+ height: 100%;
+ overflow: visible;
+}
+
+.myapps-install-progress rect {
+ fill: none;
+ stroke: var(--select-color);
+ stroke-width: 3;
+ stroke-linecap: round;
+ /* pathLength=100 normalizes the perimeter; the draw is a dashoffset
+ sweep from fully hidden to fully drawn, starting at the top. The
+ slight overshoot closes the seam at the join. */
+ stroke-dasharray: 100.5;
+ stroke-dashoffset: 100.5;
+ animation: myapps-install-draw 830ms cubic-bezier(0.4, 0, 0.2, 1) 140ms forwards;
+}
+
+/* The slot: opens from the well's resting state (opacity 0, scale .62),
+ holds while the stroke draws, recedes as the icon comes alive. */
+@keyframes myapps-install-well {
+ 0% { opacity: 0; transform: scale(0.62); }
+ 10% { opacity: 0.6; transform: scale(0.92); }
+ 70% { opacity: 0.6; transform: scale(0.92); }
+ 88% { opacity: 0; transform: scale(1.05); }
+ 100% { opacity: 0; transform: scale(1.05); }
+}
+
+/* The icon: dim and small while "installing", then alive with a spring. */
+@keyframes myapps-install-icon {
+ 0% { opacity: 0; transform: scale(0.6); filter: grayscale(1) brightness(0.9); }
+ 10% { opacity: 0.45; transform: scale(0.82); filter: grayscale(1) brightness(0.9); }
+ 70% { opacity: 0.45; transform: scale(0.82); filter: grayscale(1) brightness(0.9); }
+ 82% { opacity: 1; transform: scale(1.08); filter: grayscale(0) brightness(1); }
+ 100% { opacity: 1; transform: none; filter: grayscale(0) brightness(1); }
+}
+
+@keyframes myapps-install-draw {
+ to { stroke-dashoffset: 0; }
+}
+
+@keyframes myapps-install-progress-done {
+ to { opacity: 0; transform: scale(1.1); }
+}
+
+@keyframes myapps-install-label {
+ from { opacity: 0; }
+ to { opacity: 1; }
+}
+
/* -- Drag-to-reorder -- */
/* The tile being dragged is kept in the grid as an invisible placeholder so
@@ -1885,6 +1987,18 @@ body.myapps-reordering .myapps-tile {
transition: none;
}
+ /* The installation's end state (a plain visible tile) without its
+ staging; the progress ring shows nothing rather than a frozen arc. */
+ .myapps-tile-install-arriving::before,
+ .myapps-tile-install-arriving .myapps-tile-icon,
+ .myapps-tile-install-arriving .myapps-tile-label {
+ animation: none;
+ }
+
+ .myapps-install-progress {
+ display: none;
+ }
+
/* Folders still open and the well still fills — they just do it without
the travel: the states are information, the motion is decoration. */
.myapps-group-backdrop,
diff --git a/src/gui/src/initgui.js b/src/gui/src/initgui.js
index af929ae08..afeae91f1 100644
--- a/src/gui/src/initgui.js
+++ b/src/gui/src/initgui.js
@@ -221,10 +221,12 @@ const postAuthActions = async (action) => {
// Resolve the app's info NOW, in parallel with the tile wait
// below, so the intro never delays the launch's own server
// round-trip; the result is handed to launch_app as app_obj (the
- // same object its own fetch would produce). A failed prefetch
+ // same object its own fetch would produce, at the grid tiles'
+ // 128px icon size — the intro may have to DRAW a tile from it,
+ // when the landing is what installs the app). A failed prefetch
// hands nothing over — launch_app refetches and fails exactly
// the way it always did.
- const app_info_promise = puter.apps.get(app_name, { icon_size: 64 })
+ const app_info_promise = puter.apps.get(app_name, { icon_size: 128 })
.catch(() => null);
(async () => {
// If the app already has a tile in the Apps tab, play the
@@ -242,7 +244,10 @@ const postAuthActions = async (action) => {
let tile = null;
try {
const el_dashboard = await el_dashboard_promise;
- tile = await TabApps.beginDeepLinkLaunch(app_name, $(el_dashboard));
+ // The app-info promise lets the intro materialize a tile
+ // for an app the dashboard doesn't have yet — landing on
+ // an app is what installs it (see _spliceDeepLinkApp).
+ tile = await TabApps.beginDeepLinkLaunch(app_name, $(el_dashboard), app_info_promise);
} catch ( _e ) {
// No dashboard window — no intro; still launch.
}