collapse cross-account dupes, Lato UI font, mail-list selection & density tuning

Backend:
- Collapse identical cross-account deliveries in a thread: new messages.delivery_id
  (migration 0011) preserves the real Message-ID even when message_id_hdr is
  synthesized (§7.1); get_thread groups by it and reports also_in_account_ids; the
  list count counts distinct deliveries. Backfill script for existing rows.

Frontend:
- Bundle Lato (SIL OFL) as the UI font; unread mail-list rows use Black weight.
- Mail-list selection: hover-avatar checkbox, tri-state "select all", bulk
  Archive/Pin/Delete (Restore/Delete in Archive/Trash/Spam) with a count in the
  reading pane; unread blue dot; "also delivered to" note in the thread.
- Spacing/size tuning: sender-column width, icon gaps, avatar size, bar heights,
  and a round of -1px font sizes across the sidebar, mail list, and reading pane.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01U72FZiF1W1pb8XxyWBTREA
This commit is contained in:
2026-07-22 21:49:48 +02:00
co-authored by Claude Opus 4.8
parent fdb6ee002e
commit b4e07f11aa
28 changed files with 757 additions and 1291 deletions
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
+93
View File
@@ -0,0 +1,93 @@
Copyright (c) 2010-2014 by tyPoland Lukasz Dziedzic (team@latofonts.com) with Reserved Font Name "Lato"
This Font Software is licensed under the SIL Open Font License, Version 1.1.
This license is copied below, and is also available with a FAQ at:
https://openfontlicense.org
-----------------------------------------------------------
SIL OPEN FONT LICENSE Version 1.1 - 26 February 2007
-----------------------------------------------------------
PREAMBLE
The goals of the Open Font License (OFL) are to stimulate worldwide
development of collaborative font projects, to support the font creation
efforts of academic and linguistic communities, and to provide a free and
open framework in which fonts may be shared and improved in partnership
with others.
The OFL allows the licensed fonts to be used, studied, modified and
redistributed freely as long as they are not sold by themselves. The
fonts, including any derivative works, can be bundled, embedded,
redistributed and/or sold with any software provided that any reserved
names are not used by derivative works. The fonts and derivatives,
however, cannot be released under any other type of license. The
requirement for fonts to remain under this license does not apply
to any document created using the fonts or their derivatives.
DEFINITIONS
"Font Software" refers to the set of files released by the Copyright
Holder(s) under this license and clearly marked as such. This may
include source files, build scripts and documentation.
"Reserved Font Name" refers to any names specified as such after the
copyright statement(s).
"Original Version" refers to the collection of Font Software components as
distributed by the Copyright Holder(s).
"Modified Version" refers to any derivative made by adding to, deleting,
or substituting -- in part or in whole -- any of the components of the
Original Version, by changing formats or by porting the Font Software to a
new environment.
"Author" refers to any designer, engineer, programmer, technical
writer or other person who contributed to the Font Software.
PERMISSION & CONDITIONS
Permission is hereby granted, free of charge, to any person obtaining
a copy of the Font Software, to use, study, copy, merge, embed, modify,
redistribute, and sell modified and unmodified copies of the Font
Software, subject to the following conditions:
1) Neither the Font Software nor any of its individual components,
in Original or Modified Versions, may be sold by itself.
2) Original or Modified Versions of the Font Software may be bundled,
redistributed and/or sold with any software, provided that each copy
contains the above copyright notice and this license. These can be
included either as stand-alone text files, human-readable headers or
in the appropriate machine-readable metadata fields within text or
binary files as long as those fields can be easily viewed by the user.
3) No Modified Version of the Font Software may use the Reserved Font
Name(s) unless explicit written permission is granted by the corresponding
Copyright Holder. This restriction only applies to the primary font name as
presented to the users.
4) The name(s) of the Copyright Holder(s) or the Author(s) of the Font
Software shall not be used to promote, endorse or advertise any
Modified Version, except to acknowledge the contribution(s) of the
Copyright Holder(s) and the Author(s) or with their explicit written
permission.
5) The Font Software, modified or unmodified, in part or in whole,
must be distributed entirely under this license, and must not be
distributed under any other license. The requirement for fonts to
remain under this license does not apply to any document created
using the Font Software.
TERMINATION
This license becomes null and void if any of the above conditions are
not met.
DISCLAIMER
THE FONT SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND,
EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO ANY WARRANTIES OF
MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT
OF COPYRIGHT, PATENT, TRADEMARK, OR OTHER RIGHT. IN NO EVENT SHALL THE
COPYRIGHT HOLDER BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY,
INCLUDING ANY GENERAL, SPECIAL, INDIRECT, INCIDENTAL, OR CONSEQUENTIAL
DAMAGES, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING
FROM, OUT OF THE USE OR INABILITY TO USE THE FONT SOFTWARE OR FROM
OTHER DEALINGS IN THE FONT SOFTWARE.
File diff suppressed because one or more lines are too long

Before

Width:  |  Height:  |  Size: 151 KiB

After

Width:  |  Height:  |  Size: 20 KiB

+3 -3
View File
@@ -32,13 +32,13 @@ function DraftRow({
<div className="flex items-center gap-2">
<span className="flex-1 truncate text-sm text-app-fg">{recipients}</span>
{badge && (
<span className="shrink-0 rounded bg-app-hover px-1.5 text-[10px] text-app-muted">
<span className="shrink-0 rounded bg-app-hover px-1.5 text-[12px] text-app-muted">
{badge}
</span>
)}
<span className="shrink-0 text-[11px] text-app-faint">{time}</span>
<span className="shrink-0 text-[13px] text-app-faint">{time}</span>
</div>
<div className="truncate text-[13px] font-medium text-app-fg">{subject}</div>
<div className="truncate text-[15px] font-medium text-app-fg">{subject}</div>
{preview && <div className="truncate text-xs text-app-faint">{preview}</div>}
</button>
);
+68 -23
View File
@@ -60,6 +60,8 @@ export function MessageList({
onTrash,
onDeletePermanent,
onTogglePin,
checkedIds,
onToggleCheck,
}: {
threads: ThreadSummary[];
selectedId?: string;
@@ -70,12 +72,18 @@ export function MessageList({
onTrash: (id: string) => void;
onDeletePermanent: (id: string) => void;
onTogglePin: (id: string, pinned: boolean) => void;
checkedIds: Set<string>;
onToggleCheck: (id: string) => void;
}) {
const t = useT();
const parentRef = useRef<HTMLDivElement>(null);
// In Trash/Spam/Archive the middle action restores; elsewhere it archives.
const isRemovalFolder = folder === "trash" || folder === "junk" || folder === "archive";
// Row selection: hovering an avatar reveals a checkbox; once anything is checked,
// every row shows a checkbox (selection mode) until all are cleared.
const checkMode = checkedIds.size > 0;
const virtualizer = useVirtualizer({
count: threads.length,
getScrollElement: () => parentRef.current,
@@ -84,7 +92,7 @@ export function MessageList({
});
if (threads.length === 0) {
return <div className="p-6 text-sm text-app-faint">{t("mail.noConversations")}</div>;
return <div className="p-6 text-[15px] text-app-faint">{t("mail.noConversations")}</div>;
}
return (
@@ -94,6 +102,7 @@ export function MessageList({
const thread = threads[item.index];
const unread = thread.unread_count > 0;
const selected = selectedId === thread.id;
const checked = checkedIds.has(thread.id);
const primary = thread.participants[0];
const names =
thread.participants.map((p) => p.name || p.email).join(", ") || "(no one)";
@@ -124,35 +133,67 @@ export function MessageList({
>
<div
className={`flex h-full items-center gap-3 rounded-lg px-3 ${
selected ? "bg-app-active" : "group-hover:bg-app-hover"
selected || checked ? "bg-app-active" : "group-hover:bg-app-hover"
}`}
>
{/* Avatar + extra-participants badge */}
<div className="relative shrink-0">
<span
className="flex h-10 w-10 items-center justify-center rounded-full text-xs font-semibold text-white"
style={{ backgroundColor: avatarBg }}
>
{initials(primary)}
</span>
{extra > 0 && (
<span className="absolute -bottom-1 -left-1.5 rounded-full bg-app-hover px-1 text-[9px] font-medium text-app-muted ring-2 ring-app-surface">
+{extra}
</span>
{/* Avatar, replaced by a selection checkbox on avatar-hover or once
selection mode is active. An unread dot sits in the left margin. */}
<div className="group/av relative ml-5 h-[33px] w-[33px] shrink-0">
{unread && (
<span
aria-hidden
className="absolute -left-[21px] top-1/2 h-2 w-2 -translate-y-1/2 rounded-full bg-app-accent"
/>
)}
<div className={checkMode ? "hidden" : "contents group-hover/av:hidden"}>
<span
className="flex h-[33px] w-[33px] items-center justify-center rounded-full text-[13px] font-semibold text-white"
style={{ backgroundColor: avatarBg }}
>
{initials(primary)}
</span>
{extra > 0 && (
<span className="absolute -bottom-1 -left-1.5 rounded-full bg-app-hover px-1 text-[10px] font-medium text-app-muted ring-2 ring-app-surface">
+{extra}
</span>
)}
</div>
<button
type="button"
aria-label={t("mail.select")}
aria-pressed={checked}
onClick={stop(() => onToggleCheck(thread.id))}
className={`absolute inset-0 items-center justify-center ${
checkMode ? "flex" : "hidden group-hover/av:flex"
}`}
>
<span
className={`flex h-[17px] w-[17px] items-center justify-center rounded-md border-2 transition-colors ${
checked
? "border-app-accent bg-app-accent text-white"
: "border-app-faint hover:border-app-muted"
}`}
>
{checked && (
<svg width="11" height="11" viewBox="0 0 16 16" fill="none" stroke="currentColor" strokeWidth="2.6" strokeLinecap="round" strokeLinejoin="round">
<path d="M3.5 8.5l3 3 6-7" />
</svg>
)}
</span>
</button>
</div>
{/* Sender column */}
<div className="flex w-40 min-w-0 shrink items-center gap-1.5">
<div className="flex w-[200px] min-w-0 shrink items-center gap-1.5">
<span
className={`truncate text-sm ${
unread ? "font-semibold text-app-fg" : "text-app-fg"
className={`truncate text-[15px] ${
unread ? "font-black text-app-fg" : "text-app-fg"
}`}
>
{names}
</span>
{thread.message_count > 1 && (
<span className="shrink-0 rounded-full bg-app-hover px-1.5 text-[11px] text-app-muted">
<span className="shrink-0 rounded-full bg-app-hover px-1.5 text-[12px] text-app-muted">
{thread.message_count}
</span>
)}
@@ -161,8 +202,8 @@ export function MessageList({
{/* Subject + snippet (extra left margin -> 3x the gap to the sender). */}
<div className="ml-6 flex min-w-0 flex-1 flex-col justify-center">
<div
className={`truncate text-[13px] ${
unread ? "font-semibold text-app-fg" : "text-app-fg"
className={`truncate text-[14px] ${
unread ? "font-black text-app-fg" : "text-app-fg"
}`}
>
{thread.subject}
@@ -175,7 +216,7 @@ export function MessageList({
space while hidden), and the date is an overlay on the right that
is simply hidden on hover to reveal archive + trash — so nothing
ever shifts between the normal and hover states. */}
<div className="relative flex shrink-0 items-center justify-end gap-4">
<div className="relative flex shrink-0 items-center justify-end gap-5">
{/* Attachment: fixed slot (reserved even when empty) so the rail
width — and thus the excerpt width — is the same in every row. */}
<span className="flex w-[17px] shrink-0 justify-center text-app-faint">
@@ -221,13 +262,17 @@ export function MessageList({
onClick={stop(() =>
folder === "trash" ? onDeletePermanent(thread.id) : onTrash(thread.id),
)}
className="invisible text-app-muted hover:text-app-fg group-hover:visible"
className="invisible mr-[7px] text-app-muted hover:text-app-fg group-hover:visible"
>
<Icon name="trash" size={17} />
</button>
{/* Date overlay, aligned to the right edge (over the archive/trash
slots); hidden on hover so the actions show through. */}
<span className="absolute inset-y-0 right-0 flex items-center whitespace-nowrap text-[13px] tabular-nums text-app-faint group-hover:hidden">
<span
className={`absolute inset-y-0 right-0 flex items-center whitespace-nowrap text-[13px] tabular-nums text-app-faint group-hover:hidden ${
unread ? "font-bold" : ""
}`}
>
{timeLabel(thread.last_message_at)}
</span>
</div>
+18 -7
View File
@@ -188,7 +188,7 @@ function ActionsMenu({
onClick={() => setOpen((v) => !v)}
>
{t("mail.actions")}
<span className="text-[9px]">▾</span>
<span className="text-[11px]">▾</span>
</button>
{open && (
<div className="absolute right-0 z-20 mt-1 w-52 overflow-hidden rounded-md border border-app-border bg-app-elevated py-1 shadow-lg">
@@ -215,6 +215,7 @@ function MessageItem({
zIndex,
showReplyBar,
canReply,
alsoIn,
onToggle,
onReply,
onToggleRead,
@@ -231,6 +232,7 @@ function MessageItem({
zIndex: number;
showReplyBar: boolean;
canReply: boolean;
alsoIn: string[];
onToggle: () => void;
onReply: (kind: string) => void;
onToggleRead: () => void;
@@ -286,9 +288,9 @@ function MessageItem({
onClick={onToggle}
>
{avatar()}
<span className="min-w-0 flex-1 truncate text-sm">
<span className="min-w-0 flex-1 truncate text-[15px]">
{senderName}
<span className="ml-2 text-app-faint">{message.snippet}</span>
<span className="ml-2 text-xs text-app-faint">{message.snippet}</span>
</span>
<span className="shrink-0 text-xs text-app-faint">{fmtRelative(message.date)}</span>
</button>
@@ -302,7 +304,7 @@ function MessageItem({
className="flex w-full items-baseline justify-between gap-2 text-left"
onClick={onToggle}
>
<span className="min-w-0 truncate text-sm">
<span className="min-w-0 truncate text-[15px]">
{senderName}
{message.from_addr[0]?.email && (
<span className="text-app-faint"> &lt;{message.from_addr[0].email}&gt;</span>
@@ -320,7 +322,7 @@ function MessageItem({
<span className="truncate">
{t("mail.to")} {recipientSummary(message.to_addr)}
</span>
<span className="text-[9px] text-app-faint">{showDetails ? "▲" : "▼"}</span>
<span className="text-[11px] text-app-faint">{showDetails ? "▲" : "▼"}</span>
</button>
<div className="flex shrink-0 items-center gap-3 text-xs">
{location && <span className="font-semibold text-app-fg">{location}</span>}
@@ -355,6 +357,11 @@ function MessageItem({
/>
</div>
</div>
{alsoIn.length > 0 && (
<div className="mt-0.5 truncate text-xs text-app-faint">
{t("mail.alsoIn")} {alsoIn.join(", ")}
</div>
)}
{showDetails && (
<dl className="mt-2 grid grid-cols-[auto_1fr] gap-x-3 gap-y-0.5 border-t border-app-border pt-2 text-xs text-app-muted">
<dt className="text-app-faint">{t("mail.from")}</dt>
@@ -514,8 +521,8 @@ export function ThreadView({
return (
<div data-testid="thread-view" className="flex h-full flex-col overflow-y-auto bg-app-bg">
<div className="sticky top-0 z-10 flex h-[44px] shrink-0 items-center justify-between gap-2 border-b border-app-border bg-app-surface px-4">
<h2 className="truncate text-base font-semibold text-app-fg">{subject || "(no subject)"}</h2>
<div className="sticky top-0 z-10 flex h-[52px] shrink-0 items-center justify-between gap-2 border-b border-app-border bg-app-surface px-4">
<h2 className="truncate text-[17px] font-semibold text-app-fg">{subject || "(no subject)"}</h2>
<div className="flex shrink-0 items-center gap-1">
<button
className="rounded p-1.5 text-app-muted hover:bg-app-hover hover:text-app-fg"
@@ -558,6 +565,9 @@ export function ThreadView({
}`;
// Reply/Reply-all/Forward make no sense on a trashed message.
const canReply = specialUse.get(message.mailbox_id) !== "trash";
const alsoIn = (message.also_in_account_ids ?? [])
.map((id) => accounts?.find((a) => a.id === id)?.email)
.filter((e): e is string => Boolean(e));
return (
<Fragment key={message.id}>
<MessageItem
@@ -570,6 +580,7 @@ export function ThreadView({
zIndex={z}
showReplyBar={isNewest && composing?.anchorId !== message.id}
canReply={canReply}
alsoIn={alsoIn}
onToggle={() => toggle(message.id)}
onReply={(kind) => openReply(message, kind)}
onToggleRead={() => toggleRead(message)}
+5
View File
@@ -79,6 +79,11 @@ export const en = {
"mail.unpin": "Unpin",
"mail.delete": "Delete",
"mail.restore": "Restore",
"mail.select": "Select conversation",
"mail.selectAll": "Select all",
"mail.conversationSelected": "1 conversation selected",
"mail.conversationsSelected": "{count} conversations selected",
"mail.alsoIn": "Also delivered to",
"mail.emptyTrash": "Empty trash",
"mail.emptySpam": "Empty spam",
"mail.emptyConfirm": "Permanently delete everything in this folder? This can't be undone.",
+74 -1
View File
@@ -2,6 +2,79 @@
@tailwind components;
@tailwind utilities;
/* Lato (SIL OFL) — the UI font. Browsers only fetch the weights/styles actually
rendered, so declaring the full family is cheap. */
@font-face {
font-family: "Lato";
src: url("../fonts/Lato-Thin.ttf") format("truetype");
font-weight: 100;
font-style: normal;
font-display: swap;
}
@font-face {
font-family: "Lato";
src: url("../fonts/Lato-ThinItalic.ttf") format("truetype");
font-weight: 100;
font-style: italic;
font-display: swap;
}
@font-face {
font-family: "Lato";
src: url("../fonts/Lato-Light.ttf") format("truetype");
font-weight: 300;
font-style: normal;
font-display: swap;
}
@font-face {
font-family: "Lato";
src: url("../fonts/Lato-LightItalic.ttf") format("truetype");
font-weight: 300;
font-style: italic;
font-display: swap;
}
@font-face {
font-family: "Lato";
src: url("../fonts/Lato-Regular.ttf") format("truetype");
font-weight: 400;
font-style: normal;
font-display: swap;
}
@font-face {
font-family: "Lato";
src: url("../fonts/Lato-Italic.ttf") format("truetype");
font-weight: 400;
font-style: italic;
font-display: swap;
}
@font-face {
font-family: "Lato";
src: url("../fonts/Lato-Bold.ttf") format("truetype");
font-weight: 700;
font-style: normal;
font-display: swap;
}
@font-face {
font-family: "Lato";
src: url("../fonts/Lato-BoldItalic.ttf") format("truetype");
font-weight: 700;
font-style: italic;
font-display: swap;
}
@font-face {
font-family: "Lato";
src: url("../fonts/Lato-Black.ttf") format("truetype");
font-weight: 900;
font-style: normal;
font-display: swap;
}
@font-face {
font-family: "Lato";
src: url("../fonts/Lato-BlackItalic.ttf") format("truetype");
font-weight: 900;
font-style: italic;
font-display: swap;
}
/* Semantic theme tokens as "R G B" triplets (consumed by tailwind.config.js as
rgb(var(--x) / <alpha>)). Light is the default; .dark on <html> flips them. */
:root {
@@ -42,7 +115,7 @@ body,
}
body {
font-family: system-ui, -apple-system, "Segoe UI", Roboto, sans-serif;
font-family: "Lato", system-ui, -apple-system, "Segoe UI", Roboto, sans-serif;
background-color: rgb(var(--app-bg));
color: rgb(var(--app-fg));
-webkit-font-smoothing: antialiased;
+1
View File
@@ -57,6 +57,7 @@ export interface MessageEnvelope {
has_remote_images: boolean;
size_bytes: number;
snippet: string;
also_in_account_ids: string[];
}
export interface ThreadDetail {
+121 -14
View File
@@ -54,6 +54,16 @@ export function MailLayout() {
);
const [page, setPage] = useState(0);
const [refreshing, setRefreshing] = useState(false);
// Mail-list selection (checkboxes). Lives here so the reading pane can show the
// bulk-action bar / count while any conversation is selected.
const [checkedIds, setCheckedIds] = useState<Set<string>>(new Set());
const toggleCheck = (id: string) =>
setCheckedIds((prev) => {
const next = new Set(prev);
if (next.has(id)) next.delete(id);
else next.add(id);
return next;
});
const [composing, setComposing] = useState<ComposerInit | null>(null);
const [composerExpanded, setComposerExpanded] = useState(false);
// Bumped each time a composer is opened so the <Composer> remounts (fresh state)
@@ -124,6 +134,13 @@ export function MailLayout() {
// Reset to the first page whenever the underlying list identity changes.
useEffect(() => setPage(0), [view, filter, term, listOrder]);
// Master "select all" state over the currently listed threads.
const listedSelectedCount = pageThreads.filter((th) => checkedIds.has(th.id)).length;
const allSelected = pageThreads.length > 0 && listedSelectedCount === pageThreads.length;
const someSelected = listedSelectedCount > 0 && !allSelected;
const toggleSelectAll = () =>
setCheckedIds(allSelected ? new Set() : new Set(pageThreads.map((th) => th.id)));
const threadsRef = useRef<ThreadSummary[]>(threads);
threadsRef.current = threads;
@@ -269,6 +286,32 @@ export function MailLayout() {
queryClient.invalidateQueries({ queryKey: ["thread", id] });
};
// Selection resets when you leave the folder.
useEffect(() => setCheckedIds(new Set()), [view]);
// Bulk actions run the given operation over every selected thread, then clear the
// selection and refresh (navigating away if the open thread was among them).
const runBulk = async (action: (id: string) => Promise<unknown>) => {
const ids = [...checkedIds];
for (const id of ids) {
try {
await action(id);
} catch {
/* keep going */
}
}
setCheckedIds(new Set());
queryClient.invalidateQueries({ queryKey: ["threads"] });
queryClient.invalidateQueries({ queryKey: ["quota"] });
queryClient.invalidateQueries({ queryKey: ["folder-counts"] });
if (threadId && ids.includes(threadId)) navigate("/mail");
};
const bulkArchive = () => runBulk((id) => api.archiveThread(id));
const bulkPin = () => runBulk((id) => api.threadFlags(id, ["\\Flagged"], []));
const bulkTrash = () => runBulk((id) => api.trashThread(id));
const bulkRestore = () => runBulk((id) => api.restoreThread(id));
const bulkDeletePermanent = () => runBulk((id) => api.deleteThreadPermanent(id));
// "Empty" a Trash/Spam/Archive folder: permanently delete everything in it. The
// work runs in a worker; refresh the views after it and via the WS message.deleted.
const [emptying, setEmptying] = useState(false);
@@ -327,7 +370,7 @@ export function MailLayout() {
}`}
>
{!collapsed && (
<span className="text-xs font-semibold uppercase tracking-wide text-app-faint">
<span className="text-[13px] font-semibold uppercase tracking-wide text-app-faint">
{t("common.appName")}
</span>
)}
@@ -345,7 +388,7 @@ export function MailLayout() {
</div>
<div className={`pb-2 ${collapsed ? "px-2" : "px-3"}`}>
<button
className="flex w-full items-center justify-center gap-2 rounded-lg bg-app-accent py-2 text-sm font-medium text-app-accent-fg shadow-sm hover:opacity-90"
className="flex w-full items-center justify-center gap-2 rounded-lg bg-app-accent py-2 text-[15px] font-medium text-app-accent-fg shadow-sm hover:opacity-90"
title={collapsed ? t("nav.compose") : undefined}
onClick={() => accounts[0] && openComposer({ accountId: accounts[0].id, kind: "new" })}
>
@@ -353,7 +396,7 @@ export function MailLayout() {
{!collapsed && t("nav.compose")}
</button>
</div>
<nav className="flex-1 space-y-0.5 overflow-y-auto px-2 py-1 text-sm">
<nav className="flex-1 space-y-0.5 overflow-y-auto px-2 py-1 text-[15px]">
{FOLDERS.map((f) => {
const badge =
f.view === "folder:inbox"
@@ -375,7 +418,7 @@ export function MailLayout() {
<Icon name={f.icon} size={f.icon === "trash" ? 20 : 18} />
{!collapsed && <span className="flex-1 truncate">{t(f.label)}</span>}
{!collapsed && badge ? (
<span className="ml-auto shrink-0 tabular-nums text-xs text-app-muted">{badge}</span>
<span className="ml-auto shrink-0 tabular-nums text-[13px] text-app-muted">{badge}</span>
) : null}
</SidebarItem>
);
@@ -383,7 +426,7 @@ export function MailLayout() {
{/* Per-account views are hidden entirely when collapsed. */}
{!collapsed && (
<>
<div className="px-3 pb-1 pt-3 text-[11px] font-semibold uppercase tracking-wide text-app-faint">
<div className="px-3 pb-1 pt-3 text-[12px] font-semibold uppercase tracking-wide text-app-faint">
{t("settings.accounts")}
</div>
{accounts.map((a) => (
@@ -399,7 +442,7 @@ export function MailLayout() {
</>
)}
</nav>
<div className="border-t border-app-border p-2 text-sm">
<div className="border-t border-app-border p-2 text-[15px]">
<Link
className={`flex items-center gap-2.5 rounded-lg py-2 text-app-muted hover:bg-app-hover hover:text-app-fg ${
collapsed ? "justify-center px-0" : "px-3"
@@ -426,7 +469,7 @@ export function MailLayout() {
</button>
{!collapsed && quotaQuery.data && (
<div
className="mt-1 border-t border-app-border px-3 pb-1 pt-2 text-xs text-app-faint"
className="mt-1 border-t border-app-border px-3 pb-1 pt-2 text-[13px] text-app-faint"
title={t("nav.storageQuota")}
>
{quotaLabel(quotaQuery.data)}
@@ -446,7 +489,7 @@ export function MailLayout() {
<div className="relative w-full">
<input
ref={searchRef}
className="w-full rounded-lg border border-app-border bg-app-bg px-3 py-1.5 pr-8 text-sm text-app-fg placeholder:text-app-faint focus:border-app-accent focus:outline-none"
className="w-full rounded-lg border border-app-border bg-app-bg px-3 py-1.5 pr-8 text-[15px] text-app-fg placeholder:text-app-faint focus:border-app-accent focus:outline-none"
placeholder={t("nav.search")}
value={term}
onChange={(e) => setTerm(e.target.value)}
@@ -470,7 +513,28 @@ export function MailLayout() {
</div>
</div>
{!isDrafts && (
<div className="flex h-[44px] shrink-0 items-center gap-5 border-b border-app-border bg-app-bg px-6 text-sm">
<div className="flex h-[52px] shrink-0 items-center gap-5 border-b border-app-border bg-app-bg px-6 text-[15px]">
<button
type="button"
aria-label={t("mail.selectAll")}
title={t("mail.selectAll")}
onClick={toggleSelectAll}
className={`ml-[28px] flex h-[17px] w-[17px] shrink-0 items-center justify-center rounded-md border-2 transition-colors ${
allSelected || someSelected
? "border-app-accent bg-app-accent text-white"
: "border-app-faint hover:border-app-muted"
}`}
>
{allSelected ? (
<svg width="11" height="11" viewBox="0 0 16 16" fill="none" stroke="currentColor" strokeWidth="2.6" strokeLinecap="round" strokeLinejoin="round">
<path d="M3.5 8.5l3 3 6-7" />
</svg>
) : someSelected ? (
<svg width="11" height="11" viewBox="0 0 16 16" fill="none" stroke="currentColor" strokeWidth="2.6" strokeLinecap="round">
<path d="M4 8h8" />
</svg>
) : null}
</button>
<FilterTab active={filter === "all"} onClick={() => setFilter("all")}>
{t("filter.all")}
</FilterTab>
@@ -483,7 +547,7 @@ export function MailLayout() {
<div className="-mr-1.5 ml-auto flex items-center gap-1">
{(currentFolder === "trash" || currentFolder === "junk") && (
<button
className="rounded px-2 py-1 text-xs font-medium text-red-500 hover:bg-app-hover disabled:opacity-50"
className="rounded px-2 py-1 text-[13px] font-medium text-red-500 hover:bg-app-hover disabled:opacity-50"
onClick={emptyCurrentFolder}
disabled={emptying}
>
@@ -526,10 +590,12 @@ export function MailLayout() {
onTrash={listTrash}
onDeletePermanent={listDeletePermanent}
onTogglePin={listTogglePin}
checkedIds={checkedIds}
onToggleCheck={toggleCheck}
/>
)}
</div>
<div className="flex items-center justify-between border-t border-app-border px-6 py-1.5 text-xs text-app-muted">
<div className="flex items-center justify-between border-t border-app-border px-6 py-1.5 text-[13px] text-app-muted">
<button
className="-ml-1 rounded p-1 text-app-muted hover:bg-app-hover hover:text-app-fg disabled:opacity-60"
title={t("nav.refresh")}
@@ -569,9 +635,50 @@ export function MailLayout() {
{/* Thread pane */}
<main className="relative min-w-0 flex-1 bg-app-surface">
{threadId && detailQuery.data ? (
{checkedIds.size > 0 ? (
<div className="flex h-full flex-col">
<div className="flex h-[52px] shrink-0 items-center gap-1 border-b border-app-border bg-app-surface px-3 text-sm">
<div className="flex h-[52px] shrink-0 items-center gap-1 border-b border-app-border bg-app-surface px-3 text-[15px]">
{currentFolder === "archive" ||
currentFolder === "trash" ||
currentFolder === "junk" ? (
<>
<ToolbarButton onClick={bulkRestore}>
<Icon name="restore" size={16} />
{t("mail.restore")}
</ToolbarButton>
<ToolbarButton
onClick={currentFolder === "trash" ? bulkDeletePermanent : bulkTrash}
>
<Icon name="trash" size={16} />
{t("mail.delete")}
</ToolbarButton>
</>
) : (
<>
<ToolbarButton onClick={bulkArchive}>
<Icon name="archive" size={16} />
{t("mail.archive")}
</ToolbarButton>
<ToolbarButton onClick={bulkPin}>
<Icon name="pin" size={16} />
{t("mail.pin")}
</ToolbarButton>
<ToolbarButton onClick={bulkTrash}>
<Icon name="trash" size={16} />
{t("mail.delete")}
</ToolbarButton>
</>
)}
</div>
<div className="flex flex-1 items-center justify-center text-3xl text-app-faint">
{checkedIds.size === 1
? t("mail.conversationSelected")
: t("mail.conversationsSelected", { count: checkedIds.size })}
</div>
</div>
) : threadId && detailQuery.data ? (
<div className="flex h-full flex-col">
<div className="flex h-[52px] shrink-0 items-center gap-1 border-b border-app-border bg-app-surface px-3 text-[15px]">
{currentFolder === "archive" ? (
<>
<ToolbarButton onClick={restore}>
@@ -614,7 +721,7 @@ export function MailLayout() {
</ToolbarButton>
</>
)}
<span className="ml-auto text-xs text-app-faint">
<span className="ml-auto text-[13px] text-app-faint">
{t("mail.messageCount", { count: detailQuery.data.messages.length })}
</span>
</div>
+1
View File
@@ -26,6 +26,7 @@ function msg(id: string, minutes: number, unread = false): MessageEnvelope {
has_remote_images: false,
size_bytes: 1024,
snippet: `snippet ${id}`,
also_in_account_ids: [],
};
}
+14
View File
@@ -4,6 +4,20 @@ export default {
content: ["./index.html", "./src/**/*.{ts,tsx}"],
theme: {
extend: {
fontFamily: {
sans: ['"Lato"', "system-ui", "-apple-system", '"Segoe UI"', "Roboto", "sans-serif"],
},
// Every named text size bumped +2px over Tailwind's defaults (line-heights too).
fontSize: {
xs: ["14px", "18px"],
sm: ["16px", "22px"],
base: ["18px", "26px"],
lg: ["20px", "30px"],
xl: ["22px", "30px"],
"2xl": ["26px", "34px"],
"3xl": ["32px", "38px"],
"4xl": ["38px", "42px"],
},
// Semantic tokens backed by CSS variables (see index.css). Using these
// instead of raw grays lets every surface switch with the theme.
// Make every `border`/`divide` utility theme-aware by default, so a bare