Hang every folder off one edge, and give the drawer a way out

Two things the drill-down got wrong, both found on a phone-width window.

The folders did not line up. The rule that drops the twisty's 30px
gutter was hung on the rows offering a drill, so only folders with
children lost it -- they sat 18px left of every folder without any, and
the column of icons came apart. Whether a folder has children is not a
reason to hang it somewhere else. The class moves to the list, which is
what the indent is a property of; icons now share one column and labels
another, at every level and on the back row too.

There was no obvious way back out of the drawer. It covers the top bar
-- it is taller than it -- so the hamburger that opened it is
underneath, and pressing the same place again did nothing at all, since
that handler only ever set the drawer open. The dimmed strip beside the
drawer was the only exit, and nothing says so. There is now a close
where the hamburger was, moved by the same rule so it lands on exactly
the same pixels, and the hamburger itself toggles rather than only
opening. Escape closes it too, for a tablet with a keyboard.

Raising the top bar over the drawer instead would have been the smaller
change and is not available: the drawer is at 950 and a full-screen
composer at 800, so a top bar above the first is also above the second.
This commit is contained in:
2026-08-31 22:04:25 -07:00
parent 20929ddc2c
commit 94639e8420
13 changed files with 90 additions and 9 deletions
+1
View File
@@ -681,6 +681,7 @@ export const catalog: Catalog = {
"Shortcuts": "Kürzel", "Shortcuts": "Kürzel",
"Go to": "Gehe zu", "Go to": "Gehe zu",
"Menu": "Menü", "Menu": "Menü",
"Close menu": "Menü schließen",
"Options": "Optionen", "Options": "Optionen",
"Send options": "Sendeoptionen", "Send options": "Sendeoptionen",
"Name": "Name", "Name": "Name",
+1
View File
@@ -692,6 +692,7 @@ export const catalog: Catalog = {
"Shortcuts": "Atajos", "Shortcuts": "Atajos",
"Go to": "Ir a", "Go to": "Ir a",
"Menu": "Menú", "Menu": "Menú",
"Close menu": "Cerrar el menú",
"Options": "Opciones", "Options": "Opciones",
"Send options": "Opciones de envío", "Send options": "Opciones de envío",
"Name": "Nombre", "Name": "Nombre",
+1
View File
@@ -697,6 +697,7 @@ export const catalog: Catalog = {
"Shortcuts": "Raccourcis", "Shortcuts": "Raccourcis",
"Go to": "Aller à", "Go to": "Aller à",
"Menu": "Menu", "Menu": "Menu",
"Close menu": "Fermer le menu",
"Options": "Options", "Options": "Options",
"Send options": "Options d'envoi", "Send options": "Options d'envoi",
"Name": "Nom", "Name": "Nom",
+1
View File
@@ -694,6 +694,7 @@ export const catalog: Catalog = {
"Shortcuts": "ショートカット", "Shortcuts": "ショートカット",
"Go to": "移動", "Go to": "移動",
"Menu": "メニュー", "Menu": "メニュー",
"Close menu": "メニューを閉じる",
"Options": "オプション", "Options": "オプション",
"Send options": "送信オプション", "Send options": "送信オプション",
"Name": "名前", "Name": "名前",
+1
View File
@@ -688,6 +688,7 @@ export const catalog: Catalog = {
"Shortcuts": "Sneltoetsen", "Shortcuts": "Sneltoetsen",
"Go to": "Ga naar", "Go to": "Ga naar",
"Menu": "Menu", "Menu": "Menu",
"Close menu": "Menu sluiten",
"Options": "Opties", "Options": "Opties",
"Send options": "Verzendopties", "Send options": "Verzendopties",
"Name": "Naam", "Name": "Naam",
+1
View File
@@ -695,6 +695,7 @@ export const catalog: Catalog = {
"Shortcuts": "Atalhos", "Shortcuts": "Atalhos",
"Go to": "Ir para", "Go to": "Ir para",
"Menu": "Menu", "Menu": "Menu",
"Close menu": "Fechar o menu",
"Options": "Opções", "Options": "Opções",
"Send options": "Opções de envio", "Send options": "Opções de envio",
"Name": "Nome", "Name": "Nome",
+1
View File
@@ -694,6 +694,7 @@ export const catalog: Catalog = {
"Shortcuts": "Сочетания", "Shortcuts": "Сочетания",
"Go to": "Перейти", "Go to": "Перейти",
"Menu": "Меню", "Menu": "Меню",
"Close menu": "Закрыть меню",
"Options": "Параметры", "Options": "Параметры",
"Send options": "Параметры отправки", "Send options": "Параметры отправки",
"Name": "Имя", "Name": "Имя",
+1
View File
@@ -688,6 +688,7 @@ export const catalog: Catalog = {
"Shortcuts": "Сполучення", "Shortcuts": "Сполучення",
"Go to": "Перейти", "Go to": "Перейти",
"Menu": "Меню", "Menu": "Меню",
"Close menu": "Закрити меню",
"Options": "Параметри", "Options": "Параметри",
"Send options": "Параметри надсилання", "Send options": "Параметри надсилання",
"Name": "Ім'я", "Name": "Ім'я",
+1
View File
@@ -693,6 +693,7 @@ export const catalog: Catalog = {
"Shortcuts": "快捷键", "Shortcuts": "快捷键",
"Go to": "转到", "Go to": "转到",
"Menu": "菜单", "Menu": "菜单",
"Close menu": "关闭菜单",
"Options": "选项", "Options": "选项",
"Send options": "发送选项", "Send options": "发送选项",
"Name": "名称", "Name": "名称",
+21 -5
View File
@@ -449,15 +449,24 @@ a.menu-item:hover { color: var(--fg); }
.nav-item.folder-row .nav-twisty { position: absolute; left: calc(8px + var(--folder-indent)); top: 50%; transform: translateY(-50%); margin: 0; } .nav-item.folder-row .nav-twisty { position: absolute; left: calc(8px + var(--folder-indent)); top: 50%; transform: translateY(-50%); margin: 0; }
/* Labels sit in the same column: a 20px slot matching the folder icons. */ /* Labels sit in the same column: a 20px slot matching the folder icons. */
/* ---- Drill-down (phones; see mail/MailboxTree.tsx) ---- */ /* ---- Drill-down (phones; see mail/MailboxTree.tsx) ---- */
/* No indent to pay for, so the name gets the width the tree was spending on /*
depth. The twisty's 30px gutter goes too -- nothing is drawn in it here. */ * No indent to pay for, so the name gets the width the tree was spending on
.nav-item.folder-row.has-drill { padding-left: 12px; } * depth. The twisty's 30px gutter goes with it -- nothing is ever drawn in it
* here, whether or not the folder has children.
*
* Scoped to the list rather than to the rows that offer a drill, which is the
* distinction the first cut got wrong: only folders WITH children carried the
* class, so they sat 18px left of every folder without any and the column of
* icons came apart. Whether a folder has children is not a reason to hang it
* somewhere else.
*/
.folder-drill .nav-item.folder-row { padding-left: 12px; }
.nav-item.folder-row .drill-into { width: 44px; height: 100%; border-radius: 0; margin-right: -12px; color: var(--fg-faint); } .nav-item.folder-row .drill-into { width: 44px; height: 100%; border-radius: 0; margin-right: -12px; color: var(--fg-faint); }
.nav-item.folder-row .drill-into:hover { background: var(--bg-hover); color: var(--fg); } .nav-item.folder-row .drill-into:hover { background: var(--bg-hover); color: var(--fg); }
/* The row it belongs to is a link and paints its own active state; a second /* The row it belongs to is a link and paints its own active state; a second
filled rectangle inside it reads as a separate selected thing. */ filled rectangle inside it reads as a separate selected thing. */
.nav-item.folder-row.active .drill-into { color: inherit; } .nav-item.folder-row.active .drill-into { color: inherit; }
.drill-back { width: 100%; border: 0; background: none; font: inherit; text-align: left; cursor: pointer; color: var(--fg-muted); padding-left: 8px; } .drill-back { width: 100%; border: 0; background: none; font: inherit; text-align: left; cursor: pointer; color: var(--fg-muted); padding-left: 12px; }
.drill-back:hover { background: var(--bg-hover); color: var(--fg); } .drill-back:hover { background: var(--bg-hover); color: var(--fg); }
.drill-back svg { flex: 0 0 auto; } .drill-back svg { flex: 0 0 auto; }
.nav-item.folder-row .nav-label-color { flex: 0 0 20px; width: 20px; height: 20px; border-radius: 0; display: inline-flex; align-items: center; justify-content: center; } .nav-item.folder-row .nav-label-color { flex: 0 0 20px; width: 20px; height: 20px; border-radius: 0; display: inline-flex; align-items: center; justify-content: center; }
@@ -1058,6 +1067,11 @@ select optgroup { background-color: var(--bg-elev); color: var(--fg); }
.mobile-tabbar a.active { color: var(--accent); } .mobile-tabbar a.active { color: var(--accent); }
.main { padding-bottom: calc(60px + env(safe-area-inset-bottom)); } .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); } .sidebar { padding-bottom: env(safe-area-inset-bottom); padding-left: env(safe-area-inset-left); }
/* Lines the close up with the hamburger underneath it: same height as the
top bar, same 12px in from the edge, so the second press lands where the
first one did. The negative margin cancels the drawer's own padding-top,
which would otherwise push it a row lower than the thing it replaces. */
.drawer-head { display: flex; align-items: center; height: var(--topbar-h); margin-top: -12px; padding-left: max(6px, env(safe-area-inset-left)); }
.thread-subject { padding: 14px 16px 8px; } .thread-subject { padding: 14px 16px 8px; }
.message { margin: 0 8px 8px; } .message { margin: 0 8px 8px; }
.message-head { padding: 10px 12px; } .message-head { padding: 10px 12px; }
@@ -1108,7 +1122,9 @@ select optgroup { background-color: var(--bg-elev); color: var(--fg); }
.sidebar .nav-item, .cal-list-item { height: 44px; } .sidebar .nav-item, .cal-list-item { height: 44px; }
/* The hamburger is the one control in the corner a thumb is worst at, so it /* The hamburger is the one control in the corner a thumb is worst at, so it
gets real distance from the bezel rather than just a wider hit area. */ gets real distance from the bezel rather than just a wider hit area. */
.topbar { padding-left: max(12px, env(safe-area-inset-left)); } /* Both, together: the close in the drawer stands exactly on the hamburger it
covers, and it only does that if the two are moved by the same rule. */
.topbar, .drawer-head { padding-left: max(12px, env(safe-area-inset-left)); }
} }
@media (hover: none) { @media (hover: none) {
.msg-row .msg-check { opacity: 1; } .msg-row .msg-check { opacity: 1; }
+30 -2
View File
@@ -1,6 +1,6 @@
import { useEffect, useState, type ReactNode } from "react"; import { useEffect, useState, type ReactNode } from "react";
import { Link, useLocation } from "wouter"; import { Link, useLocation } from "wouter";
import { BookOpen, Calendar, ChevronsUpDown, FolderOpen, Globe, HelpCircle, LogOut, Mail, Menu as MenuIcon, Moon, PenSquare, Plus, RefreshCw, Settings, Sun, Upload, Users } from "lucide-react"; import { BookOpen, Calendar, ChevronsUpDown, FolderOpen, Globe, HelpCircle, LogOut, Mail, Menu as MenuIcon, Moon, PenSquare, Plus, RefreshCw, Settings, Sun, Upload, Users, X } from "lucide-react";
import { useSession } from "@/store/session"; import { useSession } from "@/store/session";
import { toggleTarget, useEffectiveTheme, useSettings } from "@/store/settings"; import { toggleTarget, useEffectiveTheme, useSettings } from "@/store/settings";
import { useMail } from "@/store/mail"; import { useMail } from "@/store/mail";
@@ -40,6 +40,14 @@ export function AppShell({ children }: { children: ReactNode }) {
useGlobalShortcuts({ onHelp: () => setHelpOpen(true) }); useGlobalShortcuts({ onHelp: () => setHelpOpen(true) });
useEffect(() => setDrawer(false), [location]); useEffect(() => setDrawer(false), [location]);
// Escape closes it too, for the tablet with a keyboard attached.
useEffect(() => {
if (!drawer) return;
const onKey = (e: KeyboardEvent) => { if (e.key === "Escape") setDrawer(false); };
window.addEventListener("keydown", onKey);
return () => window.removeEventListener("keydown", onKey);
}, [drawer]);
// Deep link: /mail?compose=new (PWA shortcut) / mailto handler // Deep link: /mail?compose=new (PWA shortcut) / mailto handler
useEffect(() => { useEffect(() => {
const params = new URLSearchParams(window.location.search); const params = new URLSearchParams(window.location.search);
@@ -68,7 +76,7 @@ export function AppShell({ children }: { children: ReactNode }) {
return ( return (
<div className="app"> <div className="app">
<header className="topbar"> <header className="topbar">
<button className="icon-btn" aria-label={t("Menu")} onClick={() => (isMobile ? setDrawer(true) : update({ sidebarCollapsed: !collapsed }))}> <button className="icon-btn" aria-label={t("Menu")} onClick={() => (isMobile ? setDrawer((d) => !d) : update({ sidebarCollapsed: !collapsed }))}>
<MenuIcon size={22} /> <MenuIcon size={22} />
</button> </button>
<Link href="/mail" className="brand"> <Link href="/mail" className="brand">
@@ -120,6 +128,26 @@ export function AppShell({ children }: { children: ReactNode }) {
<div className={`app-body ${collapsed && !isMobile ? "collapsed" : ""}`}> <div className={`app-body ${collapsed && !isMobile ? "collapsed" : ""}`}>
<div className={`drawer-backdrop ${drawer ? "open" : ""}`} onClick={() => setDrawer(false)} /> <div className={`drawer-backdrop ${drawer ? "open" : ""}`} onClick={() => setDrawer(false)} />
<aside className={`sidebar ${drawer ? "open" : ""}`}> <aside className={`sidebar ${drawer ? "open" : ""}`}>
{/*
The way back out.
The drawer covers the top bar -- it has to, being taller than it --
so the hamburger that opened it is underneath, and pressing the
same place again did nothing. That left the dimmed strip beside the
drawer as the only exit, which is not a thing anyone is told about.
Putting a close where the hamburger was means the second press
lands on the control that undoes the first, which is where the hand
is already going. It cannot be done by raising the top bar over the
drawer instead: it would then also sit over a full-screen composer,
which is stacked lower still.
*/}
{isMobile && (
<div className="drawer-head">
<button className="icon-btn" aria-label={t("Close menu")} onClick={() => setDrawer(false)}>
<X size={22} />
</button>
</div>
)}
{/* Whatever this pane is for. Files offered Compose, which wrote mail {/* Whatever this pane is for. Files offered Compose, which wrote mail
from the file manager and was the one thing nobody wanted there. */} from the file manager and was the one thing nobody wanted there. */}
<button <button
+2 -2
View File
@@ -150,7 +150,7 @@ export function MailboxTree() {
return ( return (
<> <>
<nav aria-label={t("Folders")} style={{ marginTop: 6 }}> <nav aria-label={t("Folders")} className={isMobile ? "folder-drill" : undefined} style={{ marginTop: 6 }}>
<div <div
className={`nav-section${rootDrop ? " drop-target" : ""}`} className={`nav-section${rootDrop ? " drop-target" : ""}`}
onDragOver={(e) => { onDragOver={(e) => {
@@ -318,7 +318,7 @@ function FolderRow({ mailbox: m, label, depth, hasChildren, open, hiddenUnread,
return ( return (
<Link <Link
href={`/mail/${m.id}`} href={`/mail/${m.id}`}
className={`nav-item folder-row depth-${Math.min(depth, 4)} ${onDrillIn ? "has-drill" : ""} ${currentId === m.id ? "active" : ""} ${unread ? "unread" : ""} ${dropping ? "drop-target" : ""} ${dragging ? "dragging" : ""}`} className={`nav-item folder-row depth-${Math.min(depth, 4)} ${currentId === m.id ? "active" : ""} ${unread ? "unread" : ""} ${dropping ? "drop-target" : ""} ${dragging ? "dragging" : ""}`}
title={label} title={label}
{...press} {...press}
// Dragging a folder is a mouse gesture; on a touchscreen the browser // Dragging a folder is a mouse gesture; on a touchscreen the browser
@@ -91,6 +91,34 @@ describe("folder drill-down", () => {
expect(document.querySelector(".drill-back")!.textContent).toContain("Work"); expect(document.querySelector(".drill-back")!.textContent).toContain("Work");
}); });
/*
* jsdom has no layout to measure, so this asserts the mechanism the
* alignment hangs off instead: the indent is dropped for the whole list, by
* a class on the nav. The first cut put it on the rows offering a drill,
* which meant only folders with children lost the twisty's 30px gutter and
* they hung 18px left of every folder without any.
*/
it("hangs every folder off the same edge, children or not", () => {
setWidth(390);
mount();
expect(document.querySelector("nav")!.className).toContain("folder-drill");
const depths = Array.from(document.querySelectorAll(".nav-item.folder-row")).map((r) => r.className.match(/depth-\d/)?.[0]);
expect(depths).toEqual(["depth-0", "depth-0", "depth-0"]);
drillInto("Work");
// Work has a child and Clients does not; neither may be indented for it.
expect(Array.from(document.querySelectorAll(".nav-item.folder-row")).map((r) => r.className.match(/depth-\d/)?.[0])).toEqual(["depth-0", "depth-0"]);
expect(document.querySelector(".nav-item.folder-row.has-drill")).toBeNull();
});
it("keeps the indent on a wide screen, where the tree still needs it", () => {
setWidth(1280);
mount();
expect(document.querySelector("nav")!.className).not.toContain("folder-drill");
act(() => { rowFor("Work")!.querySelector<HTMLElement>(".nav-twisty")!.click(); });
expect(rowFor("Clients")!.className).toContain("depth-1");
});
it("walks back out one level per tap", () => { it("walks back out one level per tap", () => {
setWidth(390); setWidth(390);
mount(); mount();