Phone reading pane: icon-only top bar, roomier header; dark app icon

- Top bar: icons only (names kept for screen readers and as tooltips),
  20% larger, twice the room between them; Archive 2 px smaller
- Subject wraps to two lines; subject-bar icons twice as far apart
- Reply / Translate / Actions on their own row with more room around
  it; the ▼ for the full header can no longer be squeezed out, and the
  full header spans the card's width
- Maskable app icons on the app's dark grey, so Android's launch screen
  shows the logo without a white circle

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
2026-10-19 07:59:56 +02:00
co-authored by Claude Opus 5.5
parent 21cfaa07ed
commit 1aa0673720
4 changed files with 171 additions and 106 deletions
Binary file not shown.

Before

Width:  |  Height:  |  Size: 6.8 KiB

After

Width:  |  Height:  |  Size: 7.0 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 22 KiB

After

Width:  |  Height:  |  Size: 23 KiB

+91 -62
View File
@@ -464,6 +464,8 @@ function MessageItem({
const t = useT(); const t = useT();
const { locale } = useLocale(); const { locale } = useLocale();
const [showDetails, setShowDetails] = useState(false); const [showDetails, setShowDetails] = useState(false);
// On a phone the reply / translate / actions buttons get a row of their own.
const mobile = useIsMobile();
// Translation (app/translation.py): the message's languages, as the server // Translation (app/translation.py): the message's languages, as the server
// found them ("zh-Hans,pl,en"), and the language it is shown in (null: as sent). // found them ("zh-Hans,pl,en"), and the language it is shown in (null: as sent).
const [language, setLanguage] = useState(message.content_language ?? ""); const [language, setLanguage] = useState(message.content_language ?? "");
@@ -511,6 +513,73 @@ function MessageItem({
const allTargets = replyTargets(message, ownAddresses, "reply_all"); const allTargets = replyTargets(message, ownAddresses, "reply_all");
const multiRecipient = allTargets.to.length + allTargets.cc.length > 1; const multiRecipient = allTargets.to.length + allTargets.cc.length > 1;
// The full header (From, To, Cc, Subject, Date, Size), opened with ▼.
const details = (
<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>
<dd className="truncate">{formatList(message.from_addr)}</dd>
<dt className="text-app-faint">{t("mail.to")}</dt>
<dd className="truncate">{formatList(message.to_addr)}</dd>
{message.cc_addr.length > 0 && (
<>
<dt className="text-app-faint">Cc</dt>
<dd className="truncate">{formatList(message.cc_addr)}</dd>
</>
)}
<dt className="text-app-faint">{t("mail.subjectLabel")}</dt>
<dd className="truncate">{message.subject || "(no subject)"}</dd>
<dt className="text-app-faint">{t("mail.dateLabel")}</dt>
<dd>{fmtFull(message.date)}</dd>
<dt className="text-app-faint">{t("mail.sizeLabel")}</dt>
<dd>{fmtSize(message.size_bytes)}</dd>
</dl>
);
// Reply, Translate, Actions and plugin verbs: beside the recipients, or on
// a phone on a row below them.
const actions = (
<>
{canReply && (
<button
className="flex items-center gap-1 text-app-muted hover:text-app-fg"
onClick={() => onReply(multiRecipient ? "reply_all" : "reply")}
>
<Icon name={multiRecipient ? "reply-all" : "reply"} size={14} />
{multiRecipient ? t("mail.replyAll") : t("mail.reply")}
</button>
)}
{offerTranslate && (target || translateTo) && (
<button
className="wm-online-only flex items-center gap-1 text-app-muted hover:text-app-fg"
onClick={() => setTranslateTo(translateTo ? null : target)}
>
<svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="1.8" strokeLinecap="round" strokeLinejoin="round" aria-hidden>
<path d="M4 5h9M8.5 3v2M6 5c.6 3.4 2.6 6 6 7.5M11 5c-.6 3.4-2.8 6.3-7 8" />
<path d="M13 21l4-10 4 10M14.5 17.5h5" />
</svg>
{translateTo ? t("mail.showOriginal") : t("mail.translate")}
</button>
)}
<ActionsMenu
messageId={message.id}
mailboxId={message.mailbox_id}
translateLanguages={translateCodes}
translateTo={translateTo}
onTranslateTo={setTranslateTo}
onReply={onReply}
onPrint={onPrint}
onDelete={onDelete}
replyDisabled={!canReply}
allLabels={allLabels}
messageLabels={messageLabels}
onToggleLabel={onToggleLabel}
onCreateLabel={onCreateLabel}
/>
{/* Plugin-contributed per-message actions (SPEC §11.3 message.actions). */}
<PluginSlot name="message.actions" props={{ messageId: message.id }} />
</>
);
return ( return (
<div <div
data-testid="message" data-testid="message"
@@ -574,7 +643,7 @@ function MessageItem({
<span className="truncate"> <span className="truncate">
{t("mail.to")} {recipientSummary(message.to_addr)} {t("mail.to")} {recipientSummary(message.to_addr)}
</span> </span>
<span className="text-[11px] text-app-faint">{showDetails ? "▲" : "▼"}</span> <span className="shrink-0 text-[11px] text-app-faint">{showDetails ? "▲" : "▼"}</span>
</button> </button>
<div className="flex shrink-0 items-center gap-3 text-xs"> <div className="flex shrink-0 items-center gap-3 text-xs">
{location && <span className="font-semibold text-app-fg">{location}</span>} {location && <span className="font-semibold text-app-fg">{location}</span>}
@@ -592,73 +661,22 @@ function MessageItem({
}`} }`}
/> />
</button> </button>
{canReply && ( {!mobile && actions}
<button
className="flex items-center gap-1 text-app-muted hover:text-app-fg"
onClick={() => onReply(multiRecipient ? "reply_all" : "reply")}
>
<Icon name={multiRecipient ? "reply-all" : "reply"} size={14} />
{multiRecipient ? t("mail.replyAll") : t("mail.reply")}
</button>
)}
{offerTranslate && (target || translateTo) && (
<button
className="wm-online-only flex items-center gap-1 text-app-muted hover:text-app-fg"
onClick={() => setTranslateTo(translateTo ? null : target)}
>
<svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="1.8" strokeLinecap="round" strokeLinejoin="round" aria-hidden>
<path d="M4 5h9M8.5 3v2M6 5c.6 3.4 2.6 6 6 7.5M11 5c-.6 3.4-2.8 6.3-7 8" />
<path d="M13 21l4-10 4 10M14.5 17.5h5" />
</svg>
{translateTo ? t("mail.showOriginal") : t("mail.translate")}
</button>
)}
<ActionsMenu
messageId={message.id}
mailboxId={message.mailbox_id}
translateLanguages={translateCodes}
translateTo={translateTo}
onTranslateTo={setTranslateTo}
onReply={onReply}
onPrint={onPrint}
onDelete={onDelete}
replyDisabled={!canReply}
allLabels={allLabels}
messageLabels={messageLabels}
onToggleLabel={onToggleLabel}
onCreateLabel={onCreateLabel}
/>
{/* Plugin-contributed per-message actions (SPEC §11.3 message.actions). */}
<PluginSlot name="message.actions" props={{ messageId: message.id }} />
</div> </div>
</div> </div>
{mobile && (
<div className="mb-2 mt-3 flex items-center justify-end gap-4 text-xs">{actions}</div>
)}
{alsoIn.length > 0 && ( {alsoIn.length > 0 && (
<div className="mt-0.5 truncate text-xs text-app-faint"> <div className="mt-0.5 truncate text-xs text-app-faint">
{t("mail.alsoIn")} {alsoIn.join(", ")} {t("mail.alsoIn")} {alsoIn.join(", ")}
</div> </div>
)} )}
{showDetails && ( {showDetails && !mobile && details}
<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>
<dd className="truncate">{formatList(message.from_addr)}</dd>
<dt className="text-app-faint">{t("mail.to")}</dt>
<dd className="truncate">{formatList(message.to_addr)}</dd>
{message.cc_addr.length > 0 && (
<>
<dt className="text-app-faint">Cc</dt>
<dd className="truncate">{formatList(message.cc_addr)}</dd>
</>
)}
<dt className="text-app-faint">{t("mail.subjectLabel")}</dt>
<dd className="truncate">{message.subject || "(no subject)"}</dd>
<dt className="text-app-faint">{t("mail.dateLabel")}</dt>
<dd>{fmtFull(message.date)}</dd>
<dt className="text-app-faint">{t("mail.sizeLabel")}</dt>
<dd>{fmtSize(message.size_bytes)}</dd>
</dl>
)}
</div> </div>
</div> </div>
{/* On a phone the full header takes the card's width, not the column beside the avatar. */}
{showDetails && mobile && <div className="px-4 pb-2">{details}</div>}
{/* Plugin-contributed per-message banner (SPEC §11.3 message.banner). */} {/* Plugin-contributed per-message banner (SPEC §11.3 message.banner). */}
<PluginSlot <PluginSlot
name="message.banner" name="message.banner"
@@ -891,12 +909,23 @@ export function ThreadView({
return ( return (
<div data-testid="thread-view" className="flex h-full flex-col overflow-y-auto bg-app-bg"> <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-[52px] shrink-0 items-center justify-between gap-2 border-b border-app-border bg-app-surface px-4"> <div
className={`sticky top-0 z-10 flex shrink-0 items-center justify-between gap-2 border-b border-app-border bg-app-surface px-4 ${
mobile ? "min-h-[52px] py-2" : "h-[52px]" // on a phone the subject may take two lines
}`}
>
<div className="flex min-w-0 items-center gap-2"> <div className="flex min-w-0 items-center gap-2">
<h2 className="truncate text-[17px] font-semibold text-app-fg">{subject || "(no subject)"}</h2> <h2
className={`text-[17px] font-semibold text-app-fg ${
mobile ? "line-clamp-2 break-words leading-snug" : "truncate"
}`}
>
{subject || "(no subject)"}
</h2>
<LabelChips labels={threadLabels} onRemove={(l) => removeThreadLabel.mutate(l)} /> <LabelChips labels={threadLabels} onRemove={(l) => removeThreadLabel.mutate(l)} />
</div> </div>
<div className="flex shrink-0 items-center gap-1"> {/* On a phone the icons get twice the room between them. */}
<div className={`flex shrink-0 items-center ${mobile ? "gap-5" : "gap-1"}`}>
{/* Dark message mode (lib/darkMessage.ts) — only the dark theme has it. */} {/* Dark message mode (lib/darkMessage.ts) — only the dark theme has it. */}
{resolvedTheme === "dark" && ( {resolvedTheme === "dark" && (
<button <button
+76 -40
View File
@@ -128,6 +128,8 @@ export function MailLayout() {
// Smartphone layout: one column at a time — the list, or the reading pane full // Smartphone layout: one column at a time — the list, or the reading pane full
// screen — with the sidebar as a slide-in drawer. Desktop is untouched by it. // screen — with the sidebar as a slide-in drawer. Desktop is untouched by it.
const mobile = useIsMobile(); const mobile = useIsMobile();
// The reading pane's top bar: icons only on a phone, 20% larger.
const paneIcon = mobile ? 19 : 16;
// Desktop arrangement of list and reading pane (Settings → Appearance, or the // Desktop arrangement of list and reading pane (Settings → Appearance, or the
// list's ⋯ menu → Mailbox display). A phone always uses its own single panel. // list's ⋯ menu → Mailbox display). A phone always uses its own single panel.
const readingPane = useReadingPane(); const readingPane = useReadingPane();
@@ -1923,55 +1925,78 @@ export function MailLayout() {
)} )}
{currentFolder === "archive" ? ( {currentFolder === "archive" ? (
<> <>
<ToolbarButton onClick={restore}> <ToolbarButton
<Icon name="inbox" size={16} /> onClick={restore}
{t("mail.restore")} iconOnly={mobile}
</ToolbarButton> icon={<Icon name="inbox" size={paneIcon} />}
<ToolbarButton onClick={trash}> label={t("mail.restore")}
<Icon name="trash" size={16} /> />
{t("mail.delete")} <ToolbarButton
</ToolbarButton> onClick={trash}
iconOnly={mobile}
icon={<Icon name="trash" size={paneIcon} />}
label={t("mail.delete")}
/>
</> </>
) : currentFolder === "trash" ? ( ) : currentFolder === "trash" ? (
<> <>
<ToolbarButton onClick={restore}> <ToolbarButton
<Icon name="inbox" size={16} /> onClick={restore}
{t("mail.restore")} iconOnly={mobile}
</ToolbarButton> icon={<Icon name="inbox" size={paneIcon} />}
<ToolbarButton onClick={deletePermanent}> label={t("mail.restore")}
<Icon name="trash" size={16} /> />
{t("mail.delete")} <ToolbarButton
</ToolbarButton> onClick={deletePermanent}
iconOnly={mobile}
icon={<Icon name="trash" size={paneIcon} />}
label={t("mail.delete")}
/>
</> </>
) : ( ) : (
<> <>
<ToolbarButton onClick={archive}> <ToolbarButton
<Icon name="archive" size={16} /> onClick={archive}
{t("mail.archive")} iconOnly={mobile}
</ToolbarButton> icon={<Icon name="archive" size={mobile ? paneIcon - 2 : paneIcon} />}
<ToolbarButton onClick={togglePin} active={detailQuery.data.pinned}> label={t("mail.archive")}
/>
<ToolbarButton
onClick={togglePin}
active={detailQuery.data.pinned}
iconOnly={mobile}
icon={
<Icon <Icon
name="pin" name="pin"
size={16} size={paneIcon}
className={detailQuery.data.pinned ? "text-[rgb(220_129_143)]" : ""} className={detailQuery.data.pinned ? "text-[rgb(220_129_143)]" : ""}
/> />
{detailQuery.data.pinned ? t("mail.unpin") : t("mail.pin")} }
</ToolbarButton> label={detailQuery.data.pinned ? t("mail.unpin") : t("mail.pin")}
<ToolbarButton onClick={trash}> />
<Icon name="trash" size={16} /> <ToolbarButton
{t("mail.delete")} onClick={trash}
</ToolbarButton> iconOnly={mobile}
icon={<Icon name="trash" size={paneIcon} />}
label={t("mail.delete")}
/>
{detailQuery.data.snoozed_until ? ( {detailQuery.data.snoozed_until ? (
<ToolbarButton onClick={unsnooze} active> <ToolbarButton
<Clock size={16} /> onClick={unsnooze}
{t("snooze.unsnooze")} active
</ToolbarButton> iconOnly={mobile}
icon={<Clock size={paneIcon} />}
label={t("snooze.unsnooze")}
/>
) : ( ) : (
<div className="relative"> <div className="relative">
<ToolbarButton onClick={() => setSnoozeOpen((v) => !v)} active={snoozeOpen}> <ToolbarButton
<Clock size={16} /> onClick={() => setSnoozeOpen((v) => !v)}
{t("snooze.action")} active={snoozeOpen}
</ToolbarButton> iconOnly={mobile}
icon={<Clock size={paneIcon} />}
label={t("snooze.action")}
/>
{snoozeOpen && <SnoozeMenu onPick={snoozeThread} onClose={closeSnooze} />} {snoozeOpen && <SnoozeMenu onPick={snoozeThread} onClose={closeSnooze} />}
</div> </div>
)} )}
@@ -2338,18 +2363,29 @@ function ToolbarButton({
onClick, onClick,
active = false, active = false,
children, children,
icon,
label,
iconOnly = false,
}: { }: {
onClick: () => void; onClick: () => void;
active?: boolean; active?: boolean;
children: React.ReactNode; children?: React.ReactNode;
/** With ``label``: the icon and its name, the name only for screen readers
* and as a tooltip when ``iconOnly`` (the phone layout). */
icon?: React.ReactNode;
label?: string;
iconOnly?: boolean;
}) { }) {
return ( return (
<button <button
className={`wm-online-only flex items-center gap-1.5 rounded px-2.5 py-1 hover:bg-app-hover hover:text-app-fg ${ className={`wm-online-only flex items-center gap-1.5 rounded py-1 hover:bg-app-hover hover:text-app-fg ${
active ? "text-app-fg" : "text-app-muted" iconOnly ? "px-[18px]" : "px-2.5" // icons only (phone): twice the room between them
}`} } ${active ? "text-app-fg" : "text-app-muted"}`}
onClick={onClick} onClick={onClick}
title={iconOnly ? label : undefined}
> >
{icon}
{label !== undefined && <span className={iconOnly ? "sr-only" : undefined}>{label}</span>}
{children} {children}
</button> </button>
); );