From 94639e842052a313b67fb874158aa5f0481e39e7 Mon Sep 17 00:00:00 2001 From: John Coffey Date: Mon, 31 Aug 2026 22:04:25 -0700 Subject: [PATCH] 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. --- web/src/locales/de.ts | 1 + web/src/locales/es.ts | 1 + web/src/locales/fr.ts | 1 + web/src/locales/ja.ts | 1 + web/src/locales/nl.ts | 1 + web/src/locales/pt-BR.ts | 1 + web/src/locales/ru.ts | 1 + web/src/locales/uk.ts | 1 + web/src/locales/zh-Hans.ts | 1 + web/src/styles/app.css | 26 ++++++++++++--- web/src/views/AppShell.tsx | 32 +++++++++++++++++-- web/src/views/mail/MailboxTree.tsx | 4 +-- .../mail/__tests__/mailbox-drilldown.test.tsx | 28 ++++++++++++++++ 13 files changed, 90 insertions(+), 9 deletions(-) diff --git a/web/src/locales/de.ts b/web/src/locales/de.ts index 5c4129c..e38ec4a 100644 --- a/web/src/locales/de.ts +++ b/web/src/locales/de.ts @@ -681,6 +681,7 @@ export const catalog: Catalog = { "Shortcuts": "Kürzel", "Go to": "Gehe zu", "Menu": "Menü", + "Close menu": "Menü schließen", "Options": "Optionen", "Send options": "Sendeoptionen", "Name": "Name", diff --git a/web/src/locales/es.ts b/web/src/locales/es.ts index db0b982..e9603f4 100644 --- a/web/src/locales/es.ts +++ b/web/src/locales/es.ts @@ -692,6 +692,7 @@ export const catalog: Catalog = { "Shortcuts": "Atajos", "Go to": "Ir a", "Menu": "Menú", + "Close menu": "Cerrar el menú", "Options": "Opciones", "Send options": "Opciones de envío", "Name": "Nombre", diff --git a/web/src/locales/fr.ts b/web/src/locales/fr.ts index 7f34517..4dda9f8 100644 --- a/web/src/locales/fr.ts +++ b/web/src/locales/fr.ts @@ -697,6 +697,7 @@ export const catalog: Catalog = { "Shortcuts": "Raccourcis", "Go to": "Aller à", "Menu": "Menu", + "Close menu": "Fermer le menu", "Options": "Options", "Send options": "Options d'envoi", "Name": "Nom", diff --git a/web/src/locales/ja.ts b/web/src/locales/ja.ts index 3f09ead..9ff773e 100644 --- a/web/src/locales/ja.ts +++ b/web/src/locales/ja.ts @@ -694,6 +694,7 @@ export const catalog: Catalog = { "Shortcuts": "ショートカット", "Go to": "移動", "Menu": "メニュー", + "Close menu": "メニューを閉じる", "Options": "オプション", "Send options": "送信オプション", "Name": "名前", diff --git a/web/src/locales/nl.ts b/web/src/locales/nl.ts index 6478b80..c292297 100644 --- a/web/src/locales/nl.ts +++ b/web/src/locales/nl.ts @@ -688,6 +688,7 @@ export const catalog: Catalog = { "Shortcuts": "Sneltoetsen", "Go to": "Ga naar", "Menu": "Menu", + "Close menu": "Menu sluiten", "Options": "Opties", "Send options": "Verzendopties", "Name": "Naam", diff --git a/web/src/locales/pt-BR.ts b/web/src/locales/pt-BR.ts index b281538..657ee93 100644 --- a/web/src/locales/pt-BR.ts +++ b/web/src/locales/pt-BR.ts @@ -695,6 +695,7 @@ export const catalog: Catalog = { "Shortcuts": "Atalhos", "Go to": "Ir para", "Menu": "Menu", + "Close menu": "Fechar o menu", "Options": "Opções", "Send options": "Opções de envio", "Name": "Nome", diff --git a/web/src/locales/ru.ts b/web/src/locales/ru.ts index 09daeb3..c344a71 100644 --- a/web/src/locales/ru.ts +++ b/web/src/locales/ru.ts @@ -694,6 +694,7 @@ export const catalog: Catalog = { "Shortcuts": "Сочетания", "Go to": "Перейти", "Menu": "Меню", + "Close menu": "Закрыть меню", "Options": "Параметры", "Send options": "Параметры отправки", "Name": "Имя", diff --git a/web/src/locales/uk.ts b/web/src/locales/uk.ts index a7d43c8..87a72bc 100644 --- a/web/src/locales/uk.ts +++ b/web/src/locales/uk.ts @@ -688,6 +688,7 @@ export const catalog: Catalog = { "Shortcuts": "Сполучення", "Go to": "Перейти", "Menu": "Меню", + "Close menu": "Закрити меню", "Options": "Параметри", "Send options": "Параметри надсилання", "Name": "Ім'я", diff --git a/web/src/locales/zh-Hans.ts b/web/src/locales/zh-Hans.ts index 26c4e2b..ff87e11 100644 --- a/web/src/locales/zh-Hans.ts +++ b/web/src/locales/zh-Hans.ts @@ -693,6 +693,7 @@ export const catalog: Catalog = { "Shortcuts": "快捷键", "Go to": "转到", "Menu": "菜单", + "Close menu": "关闭菜单", "Options": "选项", "Send options": "发送选项", "Name": "名称", diff --git a/web/src/styles/app.css b/web/src/styles/app.css index 1c08869..e7f5fee 100644 --- a/web/src/styles/app.css +++ b/web/src/styles/app.css @@ -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; } /* Labels sit in the same column: a 20px slot matching the folder icons. */ /* ---- 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. */ -.nav-item.folder-row.has-drill { padding-left: 12px; } +/* + * No indent to pay for, so the name gets the width the tree was spending on + * 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:hover { background: var(--bg-hover); color: var(--fg); } /* 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. */ .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 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; } @@ -1058,6 +1067,11 @@ select optgroup { background-color: var(--bg-elev); color: var(--fg); } .mobile-tabbar a.active { color: var(--accent); } .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); } + /* 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; } .message { margin: 0 8px 8px; } .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; } /* 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. */ - .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) { .msg-row .msg-check { opacity: 1; } diff --git a/web/src/views/AppShell.tsx b/web/src/views/AppShell.tsx index dccb1ec..69b1b23 100644 --- a/web/src/views/AppShell.tsx +++ b/web/src/views/AppShell.tsx @@ -1,6 +1,6 @@ import { useEffect, useState, type ReactNode } from "react"; 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 { toggleTarget, useEffectiveTheme, useSettings } from "@/store/settings"; import { useMail } from "@/store/mail"; @@ -40,6 +40,14 @@ export function AppShell({ children }: { children: ReactNode }) { useGlobalShortcuts({ onHelp: () => setHelpOpen(true) }); 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 useEffect(() => { const params = new URLSearchParams(window.location.search); @@ -68,7 +76,7 @@ export function AppShell({ children }: { children: ReactNode }) { return (
- @@ -120,6 +128,26 @@ export function AppShell({ children }: { children: ReactNode }) {
setDrawer(false)} />