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. }