feat(gui): install an app onto the grid when its landing is what installs it

A /app/<name> landing for an app the dashboard didn't list opened with
a plain fade and left the app NOWHERE: the grid had been fetched before
the launch granted the permission that installedApps reports, so Back
found no tile to minimize into and the user landed on a dashboard
without the app they were just inside. The round trip that teaches
"windows are inflated tiles" broke on exactly the landing where a new
user is forming that model.

Now the landing SAYS what it does. The app joins the grid at the tail
(where new apps land) with a tile drawn from the landing's own app-info
prefetch -- only a confirmed-real app ever materializes, with its real
icon -- held invisible until the intro has revealed the grid and
travelled to the tile's page, and then it is INSTALLED, in the grammar
every app store taught: the slot opens (the folder well's own surface)
with the icon dim inside it, a progress stroke draws clockwise around
the slot, and on completion the icon springs to full color and size as
the label names it. Only then does the flourish play and the window
grow out of the tile. The arrival is the one beat that never decays:
per-app news delivered at most once per app, not a repeated lesson.
Minimize now has a target, and the app is simply there afterward.

The regression surface is guarded on every edge: the parked-invisible
state is re-applied by renderApps itself so mid-intro re-renders can't
strip it, and settleDeepLinkLaunch -- which the landing's finally
always reaches -- force-reveals it, so no path leaves an invisible
tile. A _pendingInstalls overlay (the mirror of _removedLocal) keeps a
refresh fetched before the grant lands from evicting the tile, and
retires itself once installedApps confirms the app or the user
uninstalls it again. The order is never saved on the splice, so the
default-position append can't freeze a custom order the user never
made. Failed or absent app info adds nothing; installed-app landings
are untouched; animations-off and reduced-motion users skip the
choreography but still get the tile, silently. The progress ring is a
transient element (the tile's ::before is the well, its ::after the
running dot) drawn as an SVG dashoffset sweep -- no Houdini dependency
-- and the app-info prefetch now asks for the 128px icons every other
tile already uses.
This commit is contained in:
jelveh
2026-08-08 11:25:48 -07:00
parent 66dedf3082
commit 66824f607d
3 changed files with 315 additions and 8 deletions
+193 -5
View File
@@ -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 = '<div class="dashboard-tab-content myapps-tab">';
@@ -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 = '<svg viewBox="0 0 68 68" aria-hidden="true">'
+ '<rect x="1.5" y="1.5" width="65" height="65" rx="17" pathLength="100"/></svg>';
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/<name> (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 ) {
+114
View File
@@ -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,
+8 -3
View File
@@ -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.
}