Give the mail list the gestures a phone already has
ihasmail's mail list was built for a mouse. A row is clicked, right-clicked and dragged into a folder, and on a touchscreen two of those three do not exist -- so the phone layout had the shape of a mail app and none of the handling, and the things people reach for first simply did nothing. Four gestures, all touch-only, so a mouse keeps drag-to-folder unchanged: - Swipe a row sideways to act on it. Each direction is a setting -- right archives and left deletes by default, matching the app the phone came with -- and the strip revealed behind the row names what will happen in the folder it is happening in: "Delete forever" out of Deleted Items, "Not spam" inside Junk Mail, and nothing at all where the action is a no-op, in which case the row will not move that way. - Hold a row to select it. Selection was reachable already, by aiming at a checkbox beside an avatar, which is not how anyone selects mail on a phone. The selection toolbar gained an overflow menu at the same time: report spam, mark unread and label were hidden on narrow screens and had nowhere else to be, so touch selection could not reach them at all. - Hold a folder for the menu its ⋮ button opens. - Pull the list down to refresh, and drag in from the left edge of a conversation to go back. The toolbar's button and arrow both stay: a gesture with no visible control is one only the people who already know about it can use. The arithmetic behind them is in lib/touch.ts, away from the components and under test, because the numbers are the whole thing: an axis lock biased towards the vertical, so a diagonal flick stays a scroll rather than deleting whatever it passes over. Two layout bugs turned up while checking this on a 390px screen, both older than the gestures. The app shell is a grid with only its rows named, so it took an implicit auto column sized to the top bar's min-content -- about 470px -- and every message row ran off the right of the glass with its date beyond the edge. The column is now stated as minmax(0, 1fr), and the search field is allowed to shrink. Full-screen surfaces measure in dvh rather than vh, and the tab bar, drawer and compose button keep out from under the notch and the home indicator.
This commit is contained in:
+91
-12
@@ -350,13 +350,27 @@ a.menu-item:hover { color: var(--fg); }
|
||||
/* ==========================================================================
|
||||
App shell
|
||||
========================================================================== */
|
||||
.app { height: 100%; display: grid; grid-template-rows: var(--topbar-h) 1fr; overflow: hidden; }
|
||||
/*
|
||||
* The column is stated, and stated as `minmax(0, 1fr)`.
|
||||
*
|
||||
* A grid with only rows named gets an implicit `auto` column, which sizes to
|
||||
* its widest child's min-content -- and the top bar's min-content is the
|
||||
* search field plus the account buttons, about 470px. On a desktop nobody sees
|
||||
* it; on a 390px phone the whole app was 84px wider than the screen, so every
|
||||
* message row ran off the right edge with its date beyond the glass. `1fr`
|
||||
* alone would not have fixed it: that is `minmax(auto, 1fr)`, and the `auto`
|
||||
* floor is the same min-content. The zero floor is the part that lets the top
|
||||
* bar shrink instead of setting the width for everything below it.
|
||||
*/
|
||||
.app { height: 100%; display: grid; grid-template-rows: var(--topbar-h) 1fr; grid-template-columns: minmax(0, 1fr); overflow: hidden; }
|
||||
.topbar { display: flex; align-items: center; gap: 8px; padding: 0 12px; background: var(--bg); position: relative; z-index: 50; }
|
||||
.topbar .brand { display: flex; align-items: center; gap: 8px; font-weight: 700; font-size: 1.2em; letter-spacing: -.01em; color: var(--fg); text-decoration: none; padding-right: 8px; min-width: 0; }
|
||||
.topbar .brand img { width: 34px; height: 34px; object-fit: contain; }
|
||||
.topbar .brand .brand-name { color: #14b8a6; }
|
||||
.topbar .brand .brand-name span { color: var(--fg-muted); font-weight: 500; }
|
||||
.searchbar { flex: 1 1 auto; max-width: 720px; margin: 0 auto; position: relative; }
|
||||
/* `min-width: 0`, or the flex default of `auto` holds the search field at its
|
||||
own min-content and the account buttons beside it are pushed off a phone. */
|
||||
.searchbar { flex: 1 1 auto; min-width: 0; max-width: 720px; margin: 0 auto; position: relative; }
|
||||
.searchbar .search-input { display: flex; align-items: center; gap: 8px; height: 44px; padding: 0 8px 0 14px; border-radius: 999px; background: var(--bg-sunken); border: 1px solid transparent; transition: background .12s, box-shadow .12s, border-color .12s; }
|
||||
.searchbar .search-input:focus-within { background: var(--bg-elev); box-shadow: var(--shadow-1); border-color: var(--border); }
|
||||
.searchbar input { flex: 1; border: 0; background: transparent; outline: none; min-width: 0; height: 100%; }
|
||||
@@ -388,8 +402,8 @@ a.menu-item:hover { color: var(--fg); }
|
||||
@keyframes push-pulse { 50% { opacity: .45; } }
|
||||
@media (prefers-reduced-motion: reduce) { .push-dot.connecting { animation: none; } }
|
||||
|
||||
.app-body { display: grid; grid-template-columns: var(--sidebar-w) 1fr; min-height: 0; transition: grid-template-columns .2s var(--ease); }
|
||||
.app-body.collapsed { grid-template-columns: var(--sidebar-w-collapsed) 1fr; }
|
||||
.app-body { display: grid; grid-template-columns: var(--sidebar-w) minmax(0, 1fr); min-height: 0; transition: grid-template-columns .2s var(--ease); }
|
||||
.app-body.collapsed { grid-template-columns: var(--sidebar-w-collapsed) minmax(0, 1fr); }
|
||||
.sidebar { display: flex; flex-direction: column; min-height: 0; padding: 4px 8px 8px 8px; gap: 2px; overflow: hidden; }
|
||||
.sidebar-scroll { overflow-y: auto; overflow-x: hidden; flex: 1; min-height: 0; padding-bottom: 8px; }
|
||||
.compose-btn { display: flex; align-items: center; gap: 12px; height: 52px; padding: 0 22px 0 18px; margin: 6px 4px 12px; border-radius: 16px; background: var(--bg-elev); box-shadow: var(--shadow-1); font-weight: 600; font-size: 1em; color: var(--fg); transition: box-shadow .15s, transform .05s, background .12s; white-space: nowrap; }
|
||||
@@ -898,6 +912,56 @@ select optgroup { background-color: var(--bg-elev); color: var(--fg); }
|
||||
.login-card .pw-wrap { position: relative; }
|
||||
.login-card .pw-wrap .icon-btn { position: absolute; right: 2px; top: 1px; }
|
||||
|
||||
/* ==========================================================================
|
||||
Touch gestures (see lib/touch.ts)
|
||||
========================================================================== */
|
||||
|
||||
/*
|
||||
* `pan-y` is what makes a swipe possible without costing a scroll: the browser
|
||||
* keeps the vertical drag for itself, at its own frame rate, and only sends us
|
||||
* the horizontal movement it now knows it will not use. Without it every
|
||||
* pointermove would arrive after the scroll had already started, and the list
|
||||
* would stutter on the way past.
|
||||
*/
|
||||
.msg-row { touch-action: pan-y; -webkit-touch-callout: none; }
|
||||
.msg-row.swiping { z-index: 4; box-shadow: var(--shadow-2); }
|
||||
/* The row being dragged is the one thing on screen that must not lag. */
|
||||
.msg-row.swiping, .mail-list-pull { will-change: transform; }
|
||||
/* Stop a pull at the top of the list from dragging the page behind it. */
|
||||
.mail-list { overscroll-behavior-y: contain; }
|
||||
|
||||
/* The strip a swiped row slides off to reveal, painted into the row's slot. */
|
||||
.msg-swipe { position: absolute; left: 0; right: 0; z-index: 1; display: flex; align-items: center; padding: 0 22px; color: #fff; border-bottom: 1px solid var(--border); }
|
||||
.msg-swipe.from-left { justify-content: flex-start; }
|
||||
.msg-swipe.from-right { justify-content: flex-end; }
|
||||
.msg-swipe.danger { background: var(--danger); }
|
||||
.msg-swipe.warn { background: var(--warn); }
|
||||
.msg-swipe.accent { background: var(--accent); color: var(--accent-fg); }
|
||||
.msg-swipe.neutral { background: var(--fg-muted); }
|
||||
/*
|
||||
* Dim until the swipe is far enough to fire, then full strength and a little
|
||||
* larger. It is the same promise the haptic tap makes, for anyone whose phone
|
||||
* has no motor or has it switched off.
|
||||
*/
|
||||
.msg-swipe-act { display: inline-flex; align-items: center; gap: 10px; font-weight: 650; opacity: .72; transform: scale(.94); transition: opacity .12s var(--ease), transform .12s var(--ease); }
|
||||
.msg-swipe.armed .msg-swipe-act { opacity: 1; transform: none; }
|
||||
|
||||
/* Pull to refresh: a zero-height sticky perch for the dial, so an unpulled
|
||||
list gives up no space to it. */
|
||||
.ptr { position: sticky; top: 0; left: 0; height: 0; z-index: 5; pointer-events: none; }
|
||||
.ptr-dial { position: absolute; left: 50%; top: 0; display: inline-flex; align-items: center; justify-content: center; width: 34px; height: 34px; border-radius: 50%; background: var(--bg-elev); border: 1px solid var(--border); box-shadow: var(--shadow-2); color: var(--fg-muted); }
|
||||
.ptr-dial.armed { color: var(--accent); border-color: var(--accent); }
|
||||
|
||||
/*
|
||||
* A tap that leaves a grey rectangle behind reads as a rendering glitch rather
|
||||
* than as feedback, and every surface here has a :active or a ripple of its own.
|
||||
*/
|
||||
@media (hover: none) {
|
||||
* { -webkit-tap-highlight-color: transparent; }
|
||||
.msg-row:active { background: var(--bg-hover); }
|
||||
.nav-item, .module-link, .mobile-tabbar a { -webkit-touch-callout: none; }
|
||||
}
|
||||
|
||||
/* ==========================================================================
|
||||
Responsive
|
||||
========================================================================== */
|
||||
@@ -931,7 +995,7 @@ select optgroup { background-color: var(--bg-elev); color: var(--fg); }
|
||||
}
|
||||
@media (max-width: 768px) {
|
||||
:root { --topbar-h: 52px; }
|
||||
.app-body, .app-body.collapsed { grid-template-columns: 1fr; }
|
||||
.app-body, .app-body.collapsed { grid-template-columns: minmax(0, 1fr); }
|
||||
.sidebar { position: fixed; top: 0; bottom: 0; left: 0; width: min(300px, 85vw); background: var(--bg-elev); z-index: 950; transform: translateX(-105%); transition: transform .22s var(--ease); box-shadow: var(--shadow-3); padding-top: 12px; }
|
||||
.sidebar.open { transform: none; }
|
||||
.collapsed .sidebar .nav-item, .collapsed .sidebar .compose-btn { all: revert; }
|
||||
@@ -941,21 +1005,36 @@ select optgroup { background-color: var(--bg-elev); color: var(--fg); }
|
||||
.drawer-backdrop { display: block; position: fixed; inset: 0; z-index: 940; background: rgba(2,6,23,.45); opacity: 0; pointer-events: none; transition: opacity .2s; }
|
||||
.drawer-backdrop.open { opacity: 1; pointer-events: auto; }
|
||||
.main { border-radius: 0; border: 0; }
|
||||
.topbar { padding: 0 6px; gap: 4px; }
|
||||
/*
|
||||
index.html asks for `viewport-fit=cover`, which is what lets the app paint
|
||||
under a notch and behind the home indicator — and what makes it this
|
||||
stylesheet's job to keep anything readable out from under them. The insets
|
||||
are zero everywhere else, so these need no media query of their own.
|
||||
*/
|
||||
.topbar { padding: 0 max(6px, env(safe-area-inset-right)) 0 max(6px, env(safe-area-inset-left)); gap: 4px; }
|
||||
.topbar .brand .brand-name { display: none; }
|
||||
.searchbar .search-input { height: 40px; }
|
||||
.topbar-actions .hide-mobile { display: none; }
|
||||
.msg-row { padding-right: 8px; }
|
||||
.mail-list { padding-bottom: 72px; }
|
||||
.composer { width: 100vw; max-width: 100vw; height: 100vh; max-height: 100vh; border-radius: 0; position: fixed; inset: 0; }
|
||||
.composer.minimized { height: 44px; width: 100vw; top: auto; bottom: 0; }
|
||||
/*
|
||||
`dvh`, not `vh`. On a phone browser `100vh` is the tall layout viewport that
|
||||
ignores the address bar, so a full-screen composer put its Send button
|
||||
behind the toolbar until the page was scrolled — which a fixed element
|
||||
cannot be. In an installed PWA there is no bar and the two agree.
|
||||
*/
|
||||
.composer { width: 100vw; max-width: 100vw; height: 100dvh; max-height: 100dvh; border-radius: 0; position: fixed; inset: 0; }
|
||||
.composer.minimized { height: calc(44px + env(safe-area-inset-bottom)); width: 100vw; top: auto; bottom: 0; }
|
||||
.composer-dock { right: 0; }
|
||||
.dialog { max-height: calc(100vh - 16px); }
|
||||
.fab { display: flex; position: fixed; right: 18px; bottom: 78px; width: 56px; height: 56px; border-radius: 18px; background: var(--accent); color: var(--accent-fg); align-items: center; justify-content: center; box-shadow: var(--shadow-2); z-index: 700; }
|
||||
.mobile-tabbar { display: grid; grid-template-columns: repeat(4, 1fr); position: fixed; left: 0; right: 0; bottom: 0; height: 60px; background: var(--bg-elev); border-top: 1px solid var(--border); z-index: 700; padding-bottom: env(safe-area-inset-bottom); }
|
||||
.dialog { max-height: calc(100dvh - 16px); }
|
||||
.fab { display: flex; position: fixed; right: calc(18px + env(safe-area-inset-right)); bottom: calc(78px + env(safe-area-inset-bottom)); width: 56px; height: 56px; border-radius: 18px; background: var(--accent); color: var(--accent-fg); align-items: center; justify-content: center; box-shadow: var(--shadow-2); z-index: 700; }
|
||||
/* The bar grows by the home indicator rather than hiding behind it, and
|
||||
`.main` below reserves exactly that much. */
|
||||
.mobile-tabbar { display: grid; grid-template-columns: repeat(4, 1fr); position: fixed; left: 0; right: 0; bottom: 0; height: calc(60px + env(safe-area-inset-bottom)); background: var(--bg-elev); border-top: 1px solid var(--border); z-index: 700; padding-bottom: env(safe-area-inset-bottom); }
|
||||
.mobile-tabbar a { display: flex; flex-direction: column; align-items: center; justify-content: center; gap: 2px; color: var(--fg-muted); text-decoration: none; font-size: 11px; }
|
||||
.mobile-tabbar a.active { color: var(--accent); }
|
||||
.main { padding-bottom: 60px; }
|
||||
.main { padding-bottom: calc(60px + env(safe-area-inset-bottom)); }
|
||||
.sidebar { padding-bottom: env(safe-area-inset-bottom); padding-left: env(safe-area-inset-left); }
|
||||
.thread-subject { padding: 14px 16px 8px; }
|
||||
.message { margin: 0 8px 8px; }
|
||||
.message-head { padding: 10px 12px; }
|
||||
|
||||
Reference in New Issue
Block a user