diff --git a/README.md b/README.md
index e2493f2..1e9f553 100644
--- a/README.md
+++ b/README.md
@@ -358,6 +358,9 @@ Then put it behind your existing reverse proxy like everything else
movie-poster tiles — circular artist tiles, square album tiles,
a real hero header, and a mobile "now playing" full-screen player
— see docs/architecture.md
+- [x] Mobile menu's bottom sheet is now swipe-to-close (a real drag
+ handle, not just a decorative grabber) instead of fighting the
+ browser's own pull-to-refresh gesture — see docs/architecture.md
- [ ] Try a full party end-to-end with two real accounts (still the main gap)
- [ ] Try casting to a real Chromecast (only the SDK's own bootstrap is
confirmed here — no real device/session)
diff --git a/client/src/shell/Shell.css b/client/src/shell/Shell.css
index e2ae3d1..09a2301 100644
--- a/client/src/shell/Shell.css
+++ b/client/src/shell/Shell.css
@@ -121,6 +121,12 @@
opacity: 0;
pointer-events: none;
transition: opacity .2s;
+ /* A touch starting here has no scrollable content of its own to
+ "contain" (overscroll-behavior wouldn't apply) - this is what
+ stops the browser reading a drag over the dimmed backdrop as a
+ page-level gesture (pull-to-refresh) instead of just dismissing
+ the sheet via the existing tap-to-close below. */
+ touch-action: none;
}
.mobileNavScrim.open { opacity: 1; pointer-events: auto; }
@@ -133,6 +139,12 @@
z-index: 21;
max-height: 75vh;
overflow-y: auto;
+ /* Stops a drag that hits the top of the sheet's OWN scroll (its
+ library grid can be taller than 75vh) from chaining past this
+ element to the page underneath and surfacing as pull-to-refresh -
+ complements touch-action:none on the dedicated grabber handle
+ below, which instead covers a drag that never scrolls anything. */
+ overscroll-behavior: contain;
background: var(--bg-header);
border-radius: 16px 16px 0 0;
padding: 8px 16px calc(20px + env(safe-area-inset-bottom, 0px));
@@ -143,12 +155,27 @@
.mobileNavSheet.open { transform: translateY(0); }
+/* Dragging (see Shell.ts) suspends the transition above for 1:1
+ tracking under the finger, then this class restores it for the
+ snap-back/close animation - same "instant while dragging, animated
+ on release" split views/player/Player.css's own seek bar uses. */
+.mobileNavSheet.dragging { transition: none; }
+
+/* Bigger drag/touch target than the pill it contains (36x4px is far
+ below a real touch-target minimum) - see Shell.html/Shell.ts. */
+.sheetGrabberHandle {
+ display: flex;
+ justify-content: center;
+ padding: 14px 0 10px;
+ touch-action: none;
+ cursor: grab;
+}
+
.sheetGrabber {
width: 36px;
height: 4px;
border-radius: 99px;
background: var(--muted);
- margin: 10px auto 6px;
}
/* Accueil and the (conditional) Listes de lecture row both use this -
diff --git a/client/src/shell/Shell.html b/client/src/shell/Shell.html
index 4d2d356..df0fa01 100644
--- a/client/src/shell/Shell.html
+++ b/client/src/shell/Shell.html
@@ -32,7 +32,14 @@
-
+
+
Accueil
Bibliothèques
diff --git a/client/src/shell/Shell.ts b/client/src/shell/Shell.ts
index cc06086..8d83edb 100644
--- a/client/src/shell/Shell.ts
+++ b/client/src/shell/Shell.ts
@@ -105,7 +105,7 @@ export async function renderShell(appEl: HTMLElement): Promise
{
// ---- Mobile bottom sheet - see Shell.css's "Mobile navigation". ----
const navTrigger = appEl.querySelector('#mobileNavTrigger') as HTMLButtonElement;
- const navSheet = appEl.querySelector('#mobileNavSheet')!;
+ const navSheet = appEl.querySelector('#mobileNavSheet') as HTMLElement;
const navScrim = appEl.querySelector('#mobileNavScrim')!;
const closeMobileNav = () => {
navSheet.classList.remove('open');
@@ -127,5 +127,39 @@ export async function renderShell(appEl: HTMLElement): Promise {
});
window.addEventListener('hashchange', closeMobileNav);
+ // Drag-to-dismiss on the grabber handle only - not the whole sheet,
+ // which would fight the library grid's own scroll/tap targets (see
+ // docs/architecture.md's "Mobile navigation, take two"). Reported
+ // directly: without this, a scrim tap was the ONLY way to close the
+ // sheet, and the instinctive swipe-it-down gesture instead fell
+ // through to the browser's own pull-to-refresh (see Shell.css's
+ // touch-action/overscroll-behavior on the scrim/sheet themselves -
+ // this is the other half, giving that swipe something to actually do).
+ const CLOSE_DRAG_THRESHOLD_PX = 90;
+ const grabberHandle = appEl.querySelector('#sheetGrabberHandle') as HTMLElement;
+ let dragStartY: number | null = null;
+ let dragDeltaY = 0;
+
+ grabberHandle.addEventListener('pointerdown', (e) => {
+ dragStartY = e.clientY;
+ dragDeltaY = 0;
+ grabberHandle.setPointerCapture(e.pointerId);
+ navSheet.classList.add('dragging');
+ });
+ grabberHandle.addEventListener('pointermove', (e) => {
+ if (dragStartY === null) return;
+ dragDeltaY = Math.max(0, e.clientY - dragStartY); // only follows a downward drag - closing is the only thing this gesture does
+ navSheet.style.transform = `translateY(${dragDeltaY}px)`;
+ });
+ function endDrag() {
+ if (dragStartY === null) return;
+ dragStartY = null;
+ navSheet.classList.remove('dragging');
+ navSheet.style.transform = ''; // hands back to the .open class's own rule - either translateY(0) (snap back) or the closeMobileNav() call below immediately overrides it anyway
+ if (dragDeltaY > CLOSE_DRAG_THRESHOLD_PX) closeMobileNav();
+ }
+ grabberHandle.addEventListener('pointerup', endDrag);
+ grabberHandle.addEventListener('pointercancel', endDrag);
+
return appEl.querySelector('#mainContent') as HTMLElement;
}
diff --git a/docs/architecture.md b/docs/architecture.md
index ca01269..59e9d43 100644
--- a/docs/architecture.md
+++ b/docs/architecture.md
@@ -3775,6 +3775,61 @@ genuinely narrow viewport. **Not verified**: any of this against a
real Emby server's actual artist/album images and names, or on a real
phone.
+## Mobile navigation, take two: swipe-to-close was fighting pull-to-refresh
+
+Reported directly, against a real phone: the bottom sheet (see "Mobile
+navigation" above) had exactly one way to close it - tap the dimmed
+scrim - since the grabber was purely decorative. Swiping the sheet
+down, the instinctive gesture every real bottom sheet supports, fell
+through to the browser's own default touch handling instead: with
+nothing telling it otherwise, a downward drag starting on the scrim or
+the sheet reads to the browser as "the user wants to pull the page
+down," which on a phone is the built-in pull-to-refresh gesture - not
+a bug in this app's own logic, but a real gap in it (nothing was
+`preventDefault`-ing or claiming that gesture for the sheet instead).
+
+**Two independent parts, not one fix**: containing the browser's own
+gesture, and giving the swipe something to actually do.
+
+- `touch-action: none` on `.mobileNavScrim` and the new
+ `.sheetGrabberHandle` - tells the browser not to apply *any* of its
+ own default touch behavior (panning, pull-to-refresh) to a touch
+ starting on either, since this app's own pointer handlers own that
+ gesture now. `overscroll-behavior: contain` on `.mobileNavSheet`
+ itself covers the other half of the same problem: its own library
+ grid can be taller than the sheet's `75vh` cap, so a drag that hits
+ *that* scroll boundary needs to stop there too, not chain past the
+ sheet to the page underneath.
+- **The grabber is now a real drag handle**, not just a visual hint.
+ `#sheetGrabberHandle` wraps the small visible pill in a much bigger
+ invisible touch target (a real touch target, not a 36x4px sliver)
+ and drives the sheet with the same pointerdown/move/up(/cancel)
+ pattern `views/player/Player.css`'s own seek bar already uses:
+ 1:1 tracking under the finger while dragging (`.dragging` suspends
+ the CSS transition for this), then either snapping back open (a
+ drag under 90px) or calling the exact same `closeMobileNav()` the
+ scrim tap already used (past it) - one close path, two ways to
+ reach it, not a second parallel implementation. Deliberately scoped
+ to the grabber alone, not the whole sheet: the sheet's own content
+ (the library grid, the logout row) needs normal taps and its own
+ scroll to keep working, which a whole-sheet drag-to-close would
+ fight.
+
+Verified: full `typecheck`/`build` (client only). The CSS properties
+(`touch-action`/`overscroll-behavior`) confirmed applied via
+`getComputedStyle` against the real compiled `app.css`. The drag logic
+itself confirmed with dispatched `PointerEvent`s carrying real
+`clientY`/`pointerId` values against the real handlers copied from
+`Shell.ts`: a 40px drag snaps back (stays open, `transform` cleared),
+a 120px drag closes, and the live `transform` mid-drag tracks the
+pointer 1:1 (confirmed at an intermediate point, not just the start/end
+state) - not just asserted from reading the threshold constant.
+Existing behavior re-confirmed unchanged in the same pass: scrim-tap-
+to-close still closes. **Not verified**: actual touch input (not a
+mouse or dispatched `PointerEvent`) on a real phone - no device
+reachable from this sandbox, same standing gap as this project's other
+touch-specific work (the video/music seek bars' own drag handling).
+
## Known limitations
- Playback always goes through `hls.js` against whatever Emby's