diff --git a/src/gui/src/UI/Dashboard/TabApps.js b/src/gui/src/UI/Dashboard/TabApps.js
index ac9c62693..cac73a9e4 100644
--- a/src/gui/src/UI/Dashboard/TabApps.js
+++ b/src/gui/src/UI/Dashboard/TabApps.js
@@ -32,9 +32,7 @@ const APP_NAMES_NO_UNINSTALL = new Set([
*/
// -- Drag-to-reorder tuning --
-const DRAG_START_DISTANCE = 5; // px a mouse/pen must travel before a drag begins
-const DRAG_TOUCH_CANCEL_DISTANCE = 10; // px of finger travel that reclassifies a press as a scroll
-const DRAG_TOUCH_LONGPRESS_MS = 450; // hold time before a touch begins reordering
+const DRAG_START_DISTANCE = 5; // px a pointer must travel before a drag begins
const DRAG_EDGE_ZONE = 60; // px from a scroller edge that arms a page flip
const DRAG_EDGE_DWELL_MS = 480; // hold time at an edge before the page flips
const DRAG_FLIP_SETTLE_MS = 440; // time to let a page flip's smooth-scroll settle
@@ -47,6 +45,10 @@ const TILE_REMOVE_DELAY_MS = 500; // pause between the uninstall modal closing
// around each tile is what stops items flickering back and forth at a boundary.
const DRAG_HIT_INSET = 0.28;
+// Cog on the reorder-mode toggle; _setReorderMode swaps it for "Done" while
+// the mode is on.
+const REORDER_BTN_ICON = '';
+
// External apps (not owned by a Puter user) can report an opaque app-… id
// as their title (uid === name === title); in that case show the hostname
// of index_url instead, and open the app's website (index_url) on click —
@@ -81,6 +83,13 @@ function buildTileHtml (app) {
let h = `
`;
h += '
';
h += ``;
+ // iOS-style uninstall badge; only shown while reorder mode is on (CSS).
+ // tabindex=-1 keeps it out of the grid's roving-tabindex tab order.
+ if ( ! APP_NAMES_NO_UNINSTALL.has((app.name || '').toLowerCase()) ) {
+ h += `';
+ }
h += '
';
h += `${html_encode(title)}`;
h += '
';
@@ -329,6 +338,7 @@ const TabApps = {
_pageCount: 0,
_hasCustomOrder: false,
_drag: null,
+ _reorderMode: false,
_justDragged: false,
_reduceMotionMQL: undefined,
_loadPromise: null,
@@ -347,6 +357,9 @@ const TabApps = {
// offering email/contact autofill suggestions on focus.
h += '';
h += '';
+ // Touch devices' way into drag-to-reorder (CSS shows it only there);
+ // toggles the reorder mode — see _setReorderMode.
+ h += ``;
h += '';
h += '
';
h += '
';
@@ -355,10 +368,34 @@ const TabApps = {
},
init ($el_window) {
+ // This object outlives a closed dashboard window; a re-init gets a
+ // fresh DOM that is not in reorder mode, whatever the old one was.
+ this._reorderMode = false;
+
this.loadApps($el_window);
const self = this;
+ $el_window.on('click', '.myapps-reorder-btn', function () {
+ self._setReorderMode($el_window, ! self._reorderMode);
+ });
+
+ // Reorder mode's per-tile uninstall badge — the context-menu route to
+ // Uninstall is suppressed while the mode is on, this replaces it.
+ $el_window.on('click', '.myapps-tile-remove', function (e) {
+ e.preventDefault();
+ e.stopPropagation();
+ if ( ! self._reorderMode || self._drag ) return;
+ const $tile = $(this).closest('.myapps-tile');
+ showUninstallModal({
+ appName: $tile.attr('data-app-name'),
+ appTitle: $tile.attr('data-app-title'),
+ appUid: $tile.attr('data-app-uid'),
+ self,
+ $el_window,
+ });
+ });
+
// Tiles double as the app switcher for headless in-page apps: a
// dot marks tiles whose app has an open (or minimized) window.
// UIWindow fires this event on every window open/close.
@@ -385,6 +422,9 @@ const TabApps = {
$el_window.on('click', '.myapps-tile', function (e) {
e.preventDefault();
e.stopPropagation();
+ // In reorder mode a press on a tile is a (potential) drag pickup,
+ // never a launch.
+ if ( self._reorderMode ) return;
// A click synthesized at the end of a drag must not open the app.
if ( self._justDragged ) {
self._justDragged = false;
@@ -448,16 +488,18 @@ const TabApps = {
});
// Start a drag-to-reorder gesture. Kept separate from click so a plain
- // click still opens the app (see _onTilePointerDown for the threshold /
- // long-press logic that distinguishes the two).
+ // click still opens the app (see _onTilePointerDown for the movement
+ // threshold that distinguishes the two).
$el_window.on('pointerdown', '.myapps-tile', function (e) {
self._onTilePointerDown($el_window, e, this);
});
- // Context menu on right-click
+ // Context menu on right-click (and, where the platform fires it,
+ // touch long-press).
$el_window.on('contextmenu', '.myapps-tile', function (e) {
- // Suppress the menu (and any touch long-press callout) mid-drag.
- if ( self._drag && self._drag.started ) {
+ // Reorder mode owns every tile gesture — no menu there; likewise
+ // suppress the menu (and any long-press callout) mid-drag.
+ if ( self._reorderMode || (self._drag && self._drag.started) ) {
e.preventDefault();
return;
}
@@ -512,10 +554,9 @@ const TabApps = {
});
}
- // A touch long-press arms a drag pickup (see _onTilePointerDown). If
- // the user held rather than dragged, they want this menu — cancel the
- // pending pickup so the long-press → Uninstall path keeps working on
- // touch. An already-started drag was handled by the guard above.
+ // A pending pickup (button held, not yet moved) would be stranded
+ // under the menu; cancel it so the two can't run at once. An
+ // already-started drag was handled by the guard above.
if ( self._drag ) self._endDrag(false);
e.preventDefault();
@@ -682,6 +723,16 @@ const TabApps = {
renderApps ($el_window, { preservePage = false, instant = false } = {}) {
if ( ! this._apps ) return;
+ // The reorder toggle only earns its place once there's something to
+ // reorder (CSS additionally gates it to touch-primary devices). A
+ // background refresh can also shrink the list below two mid-mode
+ // (e.g. apps uninstalled in another window) — leave the mode then.
+ $el_window.find('.myapps-reorder-btn')
+ .toggleClass('myapps-reorder-btn-available', this._apps.length >= 2);
+ if ( this._reorderMode && this._apps.length < 2 ) {
+ this._setReorderMode($el_window, false);
+ }
+
const $container = $el_window.find('.myapps-container');
const query = String($el_window.find('.myapps-search').val() || '').toLowerCase().trim();
@@ -811,6 +862,49 @@ const TabApps = {
return !! (this._reduceMotionMQL && this._reduceMotionMQL.matches);
},
+ // -- Reorder mode (touch) --
+ // On touch, a drag must win the gesture from native scrolling *before*
+ // the finger moves — touch-action is consulted at gesture start, so no
+ // amount of long-press arming can reclaim a touch the scroller already
+ // owns (hence the flaky pre-mode behavior, worst on iOS). An explicit
+ // mode can: while it's on, CSS sets touch-action:none on the tiles, the
+ // first pointer movement begins a drag, taps don't launch, and the
+ // context menu is suppressed. Each drop still persists immediately via
+ // saveOrder (same as desktop), so the Done button only exits the mode —
+ // there is no unsaved state to lose.
+ _setReorderMode ($el_window, on) {
+ on = !! on;
+ if ( this._reorderMode === on ) return;
+ if ( on && (! this._apps || this._apps.length < 2) ) return;
+
+ if ( ! on && this._drag ) {
+ // Done tapped with another finger mid-gesture: settle the drag
+ // first — commit a started one, discard a pending pickup.
+ this._endDrag(this._drag.started);
+ }
+
+ this._reorderMode = on;
+ $el_window.find('.dashboard-tab-content.myapps-tab')
+ .toggleClass('myapps-reorder-mode', on);
+
+ // Reordering a filtered subset is ambiguous (see _onTilePointerDown),
+ // so the mode owns the unfiltered grid: clear any query and freeze
+ // the search box while the mode is on.
+ const $search = $el_window.find('.myapps-search');
+ if ( on && String($search.val() || '') !== '' ) {
+ $search.val('');
+ this.updateSearchIcons($el_window);
+ this.renderApps($el_window);
+ }
+ $search.prop('disabled', on);
+
+ $el_window.find('.myapps-reorder-btn')
+ .toggleClass('myapps-reorder-btn-active', on)
+ .attr('aria-pressed', on ? 'true' : 'false')
+ .attr('aria-label', on ? 'Done editing' : 'Edit apps')
+ .html(on ? 'Done' : REORDER_BTN_ICON);
+ },
+
// -- Drag-to-reorder --
_onTilePointerDown ($el_window, e, tileEl) {
@@ -820,11 +914,20 @@ const TabApps = {
if ( oe.button !== undefined && oe.button !== 0 ) return;
if ( this._drag ) return;
if ( ! this._apps || this._apps.length < 2 ) return;
+ // A press on the uninstall badge belongs to that button, not to a
+ // drag pickup — a finger wobble while tapping × must not lift the tile.
+ if ( oe.target && oe.target.closest && oe.target.closest('.myapps-tile-remove') ) return;
// Reordering a filtered subset is ambiguous — only reorder the full list.
const query = String($el_window.find('.myapps-search').val() || '').trim();
if ( query ) return;
const pointerType = oe.pointerType || 'mouse';
+ // Touch reorders only inside reorder mode (the button is the way in;
+ // outside it the scroller owns touch gestures and would cancel the
+ // drag anyway — see _setReorderMode). A touch press outside the mode
+ // stays a tap (launch), swipe (page), or long-press (context menu on
+ // platforms that fire it), all handled elsewhere.
+ if ( pointerType === 'touch' && ! this._reorderMode ) return;
const d = this._drag = {
$el_window,
tileEl,
@@ -837,13 +940,11 @@ const TabApps = {
offsetX: 0,
offsetY: 0,
started: false,
- readyToDrag: pointerType !== 'touch', // touch must long-press first
ghost: null,
edgeTimer: null,
edgeDir: 0,
flipping: false,
flipClearTimer: null,
- longPressTimer: null,
};
// Ignore events from a second pointer (e.g. a stray finger) so it can't
@@ -860,21 +961,6 @@ const TabApps = {
document.addEventListener('pointercancel', d.onCancel);
document.addEventListener('keydown', d.onKey);
window.addEventListener('blur', d.onBlur);
-
- // Touch: a long-press *arms* reordering (it doesn't grab the tile yet).
- // Moving after that begins the drag; holding still instead lets the
- // native long-press context menu (Uninstall) fire. A finger that moves
- // before the long-press is a page swipe and cancels the intent (see
- // _onDragPointerMove).
- if ( pointerType === 'touch' ) {
- d.longPressTimer = setTimeout(() => {
- if ( this._drag !== d || d.started ) return;
- d.readyToDrag = true;
- if ( navigator.vibrate ) {
- try { navigator.vibrate(8); } catch ( _e ) { /* not supported */ }
- }
- }, DRAG_TOUCH_LONGPRESS_MS);
- }
},
_onDragPointerMove (e) {
@@ -883,11 +969,6 @@ const TabApps = {
if ( ! d.started ) {
const dist = Math.hypot(e.clientX - d.startX, e.clientY - d.startY);
- if ( ! d.readyToDrag ) {
- // Touch, pre-long-press: a moving finger is a page swipe.
- if ( dist > DRAG_TOUCH_CANCEL_DISTANCE ) this._endDrag(false);
- return;
- }
if ( dist <= DRAG_START_DISTANCE ) return;
d.lastClientX = e.clientX;
d.lastClientY = e.clientY;
@@ -917,8 +998,9 @@ const TabApps = {
// starting a drag on a stale node would corrupt the persisted order.
if ( ! d.tileEl.isConnected ) { this._endDrag(false); return; }
d.started = true;
- clearTimeout(d.longPressTimer);
- d.longPressTimer = null;
+ if ( d.pointerType === 'touch' && navigator.vibrate ) {
+ try { navigator.vibrate(8); } catch ( _e ) { /* not supported */ }
+ }
const rect = d.tileEl.getBoundingClientRect();
d.offsetX = d.startX - rect.left;
@@ -1089,7 +1171,6 @@ const TabApps = {
document.removeEventListener('pointercancel', d.onCancel);
document.removeEventListener('keydown', d.onKey);
window.removeEventListener('blur', d.onBlur);
- clearTimeout(d.longPressTimer);
clearTimeout(d.edgeTimer);
clearTimeout(d.flipClearTimer);
},
@@ -1113,8 +1194,8 @@ const TabApps = {
const names = d.$el_window.find('.myapps-page .myapps-tile').toArray()
.map(t => t.getAttribute('data-app-name'));
const current = this._apps.map(a => a.name);
- // Only persist when the order actually changed, so an accidental
- // long-press or drop-in-place doesn't freeze the default ordering.
+ // Only persist when the order actually changed, so a pickup
+ // dropped back in place doesn't freeze the default ordering.
changed = names.length !== current.length
|| names.some((name, i) => name !== current[i]);
if ( changed ) {
diff --git a/src/gui/src/css/dashboard.css b/src/gui/src/css/dashboard.css
index d4f40feeb..1da5fc214 100644
--- a/src/gui/src/css/dashboard.css
+++ b/src/gui/src/css/dashboard.css
@@ -791,6 +791,71 @@ input.myapps-search::-webkit-search-decoration {
appearance: none;
}
+/* Frozen while reorder mode is on (see TabApps._setReorderMode). */
+input.myapps-search:disabled {
+ opacity: 0.55;
+}
+
+/* Toggle for the touch reorder mode. Hidden by default: it only appears on
+ touch-primary devices (media query below — same test as
+ isTouchPrimaryDevice()) and only once there are 2+ tiles to reorder
+ (.myapps-reorder-btn-available, kept in sync by renderApps). */
+.myapps-reorder-btn {
+ display: none;
+ flex: none;
+ -webkit-appearance: none;
+ appearance: none;
+ box-sizing: border-box;
+ padding: 8px 12px;
+ font-size: 13px;
+ font-weight: 500;
+ border: 1px solid var(--dashboard-border);
+ border-radius: 6px;
+ background: var(--dashboard-input-background);
+ color: var(--dashboard-text-primary);
+ cursor: pointer;
+ -webkit-user-select: none;
+ user-select: none;
+ touch-action: manipulation;
+ white-space: nowrap;
+}
+
+.myapps-reorder-btn svg {
+ display: block;
+ width: 16px;
+ height: 16px;
+}
+
+.myapps-reorder-btn.myapps-reorder-btn-active {
+ background: var(--select-color);
+ border-color: var(--select-color);
+ color: white;
+}
+
+@media (pointer: coarse) and (hover: none) {
+ .myapps-tab .myapps-reorder-btn.myapps-reorder-btn-available {
+ display: inline-flex;
+ align-items: center;
+ }
+
+ /* Sit the button beside the search box. With the button hidden (or on
+ devices where it never shows) the lone flex child centers at the same
+ 360px max width the block layout gave it. */
+ .myapps-tab .myapps-search-wrap {
+ display: flex;
+ align-items: center;
+ justify-content: center;
+ gap: 10px;
+ }
+
+ .myapps-tab .myapps-search-inner {
+ flex: 1 1 auto;
+ max-width: 360px;
+ margin-left: 0;
+ margin-right: 0;
+ }
+}
+
.myapps-container {
/* Pager geometry; TabApps.js reads these to size pages. */
--myapps-tile-w: 100px;
@@ -988,6 +1053,8 @@ input.myapps-search::-webkit-search-decoration {
this, an overflowing label makes flexbox shrink the icon box, deforming
the icon into a landscape crop. */
flex: none;
+ /* Positioning context for the reorder-mode uninstall badge. */
+ position: relative;
width: 56px;
height: 56px;
overflow: hidden;
@@ -1394,13 +1461,17 @@ input.myapps-search::-webkit-search-decoration {
}
/* Floating clone that tracks the pointer. Lives on so the scroller's
- overflow can't clip it; positioned entirely via an inline transform. */
+ overflow can't clip it; positioned entirely via an inline transform.
+ z-index must clear every window band — the fullpage dashboard window sits
+ at ~1e7 and stay-on-top windows near 1e9 (see UIWindow), and a ghost that
+ loses that contest renders invisibly behind the very grid being dragged.
+ Max int is safe: the ghost is transient, purely visual, pointer-events:none. */
.myapps-drag-ghost {
position: fixed;
top: 0;
left: 0;
margin: 0;
- z-index: 100000;
+ z-index: 2147483647;
pointer-events: none;
opacity: 0.95;
filter: drop-shadow(0 10px 20px var(--dashboard-shadow-medium));
@@ -1435,6 +1506,86 @@ body.myapps-reordering .myapps-tile {
touch-action: none;
}
+/* -- Reorder mode (touch; see TabApps._setReorderMode) --
+ touch-action must be locked down BEFORE the finger goes down — the browser
+ reads it at gesture start, so the mid-drag body.myapps-reordering rule
+ above can't stop a touch the scroller has already claimed. While the mode
+ is on, touches that start on a tile belong to the drag logic (ones on the
+ gaps still pan pages natively) and the iOS long-press callout is off. */
+.myapps-reorder-mode .myapps-tile {
+ touch-action: none;
+ -webkit-touch-callout: none;
+}
+
+/* iOS-style uninstall badge (see buildTileHtml); rendered on every tile that
+ may be uninstalled but only shown while reorder mode is on. Fixed dark
+ grey / white ×, like iOS, works over any theme. */
+.myapps-tile-remove {
+ display: none;
+ position: absolute;
+ top: -7px;
+ left: -7px;
+ width: 22px;
+ height: 22px;
+ padding: 0;
+ border: none;
+ border-radius: 50%;
+ background: rgba(58, 58, 64, 0.92);
+ color: #fff;
+ box-shadow: 0 1px 4px rgba(0, 0, 0, 0.35);
+ cursor: pointer;
+ align-items: center;
+ justify-content: center;
+ z-index: 1;
+ touch-action: manipulation;
+}
+
+.myapps-tile-remove svg {
+ width: 11px;
+ height: 11px;
+}
+
+/* Pad the 22px badge out to a finger-sized target. */
+.myapps-tile-remove::after {
+ content: '';
+ position: absolute;
+ inset: -7px;
+}
+
+.myapps-reorder-mode .myapps-tile-remove {
+ display: flex;
+}
+
+/* The badge overhangs the icon box's corner; the box normally clips. */
+.myapps-reorder-mode .myapps-tile-icon {
+ overflow: visible;
+}
+
+/* iOS-home-screen jiggle: the visible cue that tiles are draggable. Applied
+ to the icon, not the tile, so it can't fight the FLIP translate transforms
+ the drag logic puts on the tiles themselves. Phases are staggered by grid
+ position so the tiles don't wobble in lockstep. */
+@keyframes myapps-reorder-jiggle {
+ 0% { transform: rotate(-1.5deg); }
+ 50% { transform: rotate(1.5deg); }
+ 100% { transform: rotate(-1.5deg); }
+}
+
+@media (prefers-reduced-motion: no-preference) {
+ .myapps-reorder-mode .myapps-tile .myapps-tile-icon {
+ animation: myapps-reorder-jiggle 0.35s ease-in-out infinite;
+ }
+
+ .myapps-reorder-mode .myapps-tile:nth-child(2n) .myapps-tile-icon {
+ animation-duration: 0.33s;
+ animation-delay: -0.12s;
+ }
+
+ .myapps-reorder-mode .myapps-tile:nth-child(3n) .myapps-tile-icon {
+ animation-delay: -0.24s;
+ }
+}
+
@media (prefers-reduced-motion: reduce) {
.myapps-drag-ghost {
transition: none;