Interface size setting: Default, +10%, +20%

Mailbox display and Settings → Appearance get an interface-size picker,
stored per device in localStorage and applied as CSS zoom on <html>.
Viewport-height layouts divide by --ui-zoom, and code that turns mouse
coordinates or bounding rects into CSS lengths (resizers, compose dock,
emoji picker, editor popups and image handles, swipe-to-delete) divides
by the zoom factor.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
2026-10-25 15:20:39 +01:00
co-authored by Claude Opus 5.5
parent d8e6cca66b
commit a4581ab199
14 changed files with 172 additions and 28 deletions
+3 -1
View File
@@ -1,3 +1,5 @@
import { uiZoom } from "../lib/uiScale";
// A drag handle between two panels. Vertical (the default) sits between panels
// side by side and reports horizontal movement; horizontal sits between panels
// stacked one above the other (the "reading pane at the bottom" layout) and
@@ -17,7 +19,7 @@ export function Resizer({
let last = vertical ? e.clientX : e.clientY;
const onMove = (ev: MouseEvent) => {
const now = vertical ? ev.clientX : ev.clientY;
onDelta(now - last);
onDelta((now - last) / uiZoom()); // screen px → CSS px under the interface zoom
last = now;
};
const onUp = () => {
@@ -1,6 +1,7 @@
import { useEffect, useRef, useState } from "react";
import { useT } from "../../i18n";
import { uiZoom } from "../../lib/uiScale";
import { Composer } from "./Composer";
import { CHIP_W, type ComposeWin, DOCK_INSET, layoutDock } from "./windows";
@@ -8,7 +9,7 @@ function useViewportSize() {
const read = () =>
typeof window === "undefined"
? { vw: 1280, vh: 800 }
: { vw: window.innerWidth, vh: window.innerHeight };
: { vw: window.innerWidth / uiZoom(), vh: window.innerHeight / uiZoom() };
const [size, setSize] = useState(read);
useEffect(() => {
const onResize = () => setSize(read());
@@ -3,6 +3,7 @@ import { createPortal } from "react-dom";
import { useT } from "../../i18n";
import { useTheme } from "../../lib/theme";
import { uiZoom } from "../../lib/uiScale";
// emoji-mart, loaded on first use: its emoji data is a few hundred KB that the
// rest of the app does without. The data is bundled — nothing is fetched from
@@ -82,13 +83,18 @@ export function EmojiButton({
const [pos, setPos] = useState<{ left: number; top: number } | null>(null);
const open = () => {
const r = btnRef.current?.getBoundingClientRect();
if (!r) return;
const sr = btnRef.current?.getBoundingClientRect();
if (!sr) return;
onOpen();
// Screen px → CSS px under the interface zoom, so the picker lands on the button.
const z = uiZoom();
const r = { left: sr.left / z, top: sr.top / z, bottom: sr.bottom / z };
const vw = window.innerWidth / z;
const vh = window.innerHeight / z;
// Below the button, or above it when the window ends first; inside the viewport.
const below = r.bottom + 6 + PICKER_H <= window.innerHeight;
const below = r.bottom + 6 + PICKER_H <= vh;
setPos({
left: Math.max(8, Math.min(r.left, window.innerWidth - PICKER_W - 8)),
left: Math.max(8, Math.min(r.left, vw - PICKER_W - 8)),
top: below ? r.bottom + 6 : Math.max(8, r.top - 6 - PICKER_H),
});
};
@@ -11,6 +11,7 @@ import {
import { useT } from "../../i18n";
import { api, ApiError } from "../../lib/api";
import { uiZoom } from "../../lib/uiScale";
import { notify } from "../AppDialog";
import { EmojiButton } from "./EmojiButton";
import {
@@ -581,7 +582,8 @@ export const RichTextEditor = forwardRef<
if (!img || !ed) return null;
const ir = img.getBoundingClientRect();
const er = ed.getBoundingClientRect();
return { left: ir.left - er.left, top: ir.top - er.top, width: ir.width, height: ir.height };
const z = uiZoom(); // rects are screen px; the handles are placed in CSS px
return { left: (ir.left - er.left) / z, top: (ir.top - er.top) / z, width: ir.width / z, height: ir.height / z };
}, [selImg]);
useEffect(() => setBox(computeBox()), [selImg, computeBox]);
@@ -610,10 +612,11 @@ export const RichTextEditor = forwardRef<
const ed = ref.current;
if (!img || !ed) return;
const startX = e.clientX;
const startW = img.getBoundingClientRect().width;
const z = uiZoom();
const startW = img.getBoundingClientRect().width / z;
const maxW = ed.clientWidth - 24; // minus px-3 padding
const onMove = (ev: MouseEvent) => {
const dx = ev.clientX - startX;
const dx = (ev.clientX - startX) / z;
const grow = corner === "se" || corner === "ne" ? dx : -dx;
img.style.width = `${Math.round(Math.max(24, Math.min(startW + grow, maxW)))}px`;
img.style.height = "auto";
@@ -663,8 +666,9 @@ export const RichTextEditor = forwardRef<
if (!ed || !spellIssue || !stillValid(spellIssue)) return null;
const r = spellIssue.range.getBoundingClientRect();
const er = ed.getBoundingClientRect();
const z = uiZoom();
return {
left: Math.max(8, Math.min(r.left - er.left, er.width - 300)),
left: Math.max(8, Math.min((r.left - er.left) / z, er.width / z - 300)),
top: popupTop(r, spellPopupRef.current?.offsetHeight || 160),
};
// eslint-disable-next-line react-hooks/exhaustive-deps
@@ -714,14 +718,17 @@ export const RichTextEditor = forwardRef<
// A popup's top edge, relative to the editor: just under its anchor, or just
// above it when there is no room left below — inside the editor's visible
// box and the window alike — so it is never cut off at the bottom.
const popupTop = (anchor: DOMRect, popupH: number) => {
const popupTop = (anchor: DOMRect, cssPopupH: number) => {
// Worked out in screen px (the rects'), returned in CSS px for the style.
const z = uiZoom();
const popupH = cssPopupH * z;
const er = ref.current!.getBoundingClientRect();
const bottom = Math.min(er.bottom, window.innerHeight) - 4;
const top = Math.max(er.top, 0) + 4;
const below = anchor.bottom + 4;
const above = anchor.top - 4 - popupH;
const fitsBelow = below + popupH <= bottom;
return (fitsBelow || above < top ? below : above) - er.top;
return ((fitsBelow || above < top ? below : above) - er.top) / z;
};
// Where the link popup goes: just under the link (or the text becoming one),
@@ -738,7 +745,8 @@ export const RichTextEditor = forwardRef<
}
const er = ed.getBoundingClientRect();
if (!ar || (!ar.width && !ar.height && !ar.top)) return newLinkRange ? { left: 12, top: 12 } : null;
const left = Math.max(8, Math.min(ar.left - er.left, er.width - 320));
const z = uiZoom();
const left = Math.max(8, Math.min((ar.left - er.left) / z, er.width / z - 320));
return { left, top: popupTop(ar, linkPopupRef.current?.offsetHeight || 44) };
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [selLink, newLinkRange]);
+4 -3
View File
@@ -9,6 +9,7 @@ import { avatarColor } from "../../lib/avatarColor";
import { useSavedThreads } from "../../lib/offline";
import { formatClock, getDateLocale } from "../../lib/timeFormat";
import type { Address, ThreadSummary } from "../../lib/types";
import { uiZoom } from "../../lib/uiScale";
import { Icon } from "../Icon";
import { Clock } from "../Offline";
import { AvatarImage } from "../SenderAvatar";
@@ -554,8 +555,8 @@ function useSwipeToDelete(onDelete?: (id: string) => void) {
onPointerMove: (e: React.PointerEvent<HTMLDivElement>) => {
const s = start.current;
if (!s || s.id !== id) return;
const dx = e.clientX - s.x;
const dy = e.clientY - s.y;
const dx = (e.clientX - s.x) / uiZoom(); // CSS px, like the row's offsetWidth
const dy = (e.clientY - s.y) / uiZoom();
if (!s.active) {
if (Math.abs(dy) > SWIPE_START_PX && Math.abs(dy) > Math.abs(dx)) {
start.current = null; // a scroll
@@ -572,7 +573,7 @@ function useSwipeToDelete(onDelete?: (id: string) => void) {
start.current = null;
if (!s || !s.active || s.id !== id) return;
swipedAt.current = Date.now();
const dx = Math.max(0, e.clientX - s.x);
const dx = Math.max(0, (e.clientX - s.x) / uiZoom());
if (dx > s.width * SWIPE_DELETE_SHARE) {
setState({ id, dx: s.width, dragging: false }); // off it goes
window.setTimeout(() => {
@@ -3,11 +3,13 @@ import { useEffect, useRef } from "react";
import { useT } from "../../i18n";
import { ListDividersOption } from "./ListDividersOption";
import { ReadingPanePicker } from "./ReadingPanePicker";
import { UiScaleOption } from "./UiScaleOption";
/**
* "Mailbox display": the reading-pane choice and the row dividers, opened from
* "Mailbox display": the reading-pane choice, the row dividers and the interface
* size, opened from
* the list's ⋯ menu. Anchored below that menu. Picking a layout applies it and
* closes the popover; the dividers switch applies at once and leaves it open;
* closes the popover; the dividers switch and the size apply at once and leave it open;
* ✕, Escape or a click elsewhere closes it.
*/
export function MailboxDisplay({
@@ -59,12 +61,16 @@ export function MailboxDisplay({
<>
<p className="mb-2 text-[13px] font-medium text-app-fg">{t("display.readingPane")}</p>
<ReadingPanePicker onPick={onClose} />
<div className="mt-4 border-t border-app-border pt-3">
<div className="mt-4 space-y-3 border-t border-app-border pt-3">
<ListDividersOption />
<UiScaleOption />
</div>
</>
) : (
<ListDividersOption />
<div className="space-y-3">
<ListDividersOption />
<UiScaleOption />
</div>
)}
</div>
);
@@ -0,0 +1,30 @@
import { useT } from "../../i18n";
import { setUiScale, UI_SCALES, useUiScale } from "../../lib/uiScale";
/** "Interface size" — Default / +10% / +20%, in the ⋯ menu's Mailbox display
* and in Settings → Appearance. Applies at once. */
export function UiScaleOption() {
const t = useT();
const scale = useUiScale();
return (
<div className="flex items-center justify-between gap-3 text-[14px] text-app-fg">
<span>{t("display.size")}</span>
<div role="radiogroup" aria-label={t("display.size")} className="flex gap-1 rounded-lg border border-app-border p-0.5">
{UI_SCALES.map((value) => (
<button
key={value}
type="button"
role="radio"
aria-checked={scale === value}
onClick={() => setUiScale(value)}
className={`rounded px-2.5 py-0.5 ${
scale === value ? "bg-app-active font-medium text-app-fg" : "text-app-muted hover:bg-app-hover"
}`}
>
{value === 1 ? t("display.sizeDefault") : `+${Math.round((value - 1) * 100)}%`}
</button>
))}
</div>
</div>
);
}
+2
View File
@@ -670,6 +670,8 @@ export const en = {
"display.bottom": "Bottom",
"display.off": "Off",
"display.dividers": "Show a dividing line between rows",
"display.size": "Interface size",
"display.sizeDefault": "Default",
"admin.mail": "Outgoing system mail",
"admin.mailHelp":
"The mail account the server sends password-reset links from. Without it, “Forgot password?” sends nothing.",
+2
View File
@@ -680,6 +680,8 @@ export const pl: Catalog = {
"display.bottom": "Na dole",
"display.off": "Wyłączone",
"display.dividers": "Pokazuj linię między wierszami",
"display.size": "Rozmiar interfejsu",
"display.sizeDefault": "Domyślny",
"admin.mail": "Wychodząca poczta systemowa",
"admin.mailHelp":
"Konto pocztowe, z którego serwer wysyła linki do resetowania hasła. Bez niego „Nie pamiętasz hasła?” niczego nie wyśle.",
+75
View File
@@ -0,0 +1,75 @@
// Interface size (Mailbox display / Settings → Appearance): Default, +10% or
// +20%. Per device, in localStorage, like the reading-pane layout.
//
// Applied as CSS `zoom` on <html>, which scales everything evenly — px and rem
// sizes, inline styles, SVG icons and the message iframes alike. Two things zoom
// does NOT hide, and the app compensates for:
// - viewport units are zoomed too, so a 100vh box would overflow; `h-screen`
// and the dvh heights divide by --ui-zoom (tailwind.config.js, index.css).
// - mouse coordinates, getBoundingClientRect() and innerWidth/innerHeight are
// in unzoomed screen px, while a style written from them is zoomed again;
// divide by uiZoom() before turning them into a CSS length.
import { useSyncExternalStore } from "react";
export const UI_SCALES = [1, 1.1, 1.2] as const;
export type UiScale = (typeof UI_SCALES)[number];
const KEY = "ui.scale";
const EVENT = "webmail:ui-scale";
function read(): UiScale {
try {
const v = Number(localStorage.getItem(KEY));
return (UI_SCALES as readonly number[]).includes(v) ? (v as UiScale) : 1;
} catch {
return 1;
}
}
/** The current zoom factor, for converting screen px into CSS px. */
export function uiZoom(): number {
return read();
}
let applied = 1;
function apply(): void {
const z = read();
if (z === applied) return; // the storage event fires for every key
applied = z;
const root = document.documentElement;
root.style.zoom = z === 1 ? "" : String(z);
root.style.setProperty("--ui-zoom", String(z));
// The viewport in CSS px just changed size; let everything that measures it
// on "resize" (panel clamps, the compose dock, popups) measure again.
window.dispatchEvent(new Event("resize"));
}
function subscribe(onChange: () => void): () => void {
// Our own writes (same tab) and other tabs' writes (the storage event).
window.addEventListener(EVENT, onChange);
window.addEventListener("storage", onChange);
return () => {
window.removeEventListener(EVENT, onChange);
window.removeEventListener("storage", onChange);
};
}
export function useUiScale(): UiScale {
return useSyncExternalStore(subscribe, read, () => 1);
}
export function setUiScale(scale: UiScale): void {
try {
localStorage.setItem(KEY, String(scale));
} catch {
/* private mode: the choice simply does not stick */
}
window.dispatchEvent(new Event(EVENT));
}
// Applied before the first render (imported from main.tsx), so the app never
// paints at the wrong size, and kept in step with later changes from any tab.
apply();
subscribe(apply);
+1
View File
@@ -8,6 +8,7 @@ import { I18nProvider } from "./i18n";
import { isSessionGone, signedOut } from "./lib/auth";
import "./lib/install"; // catches the install event, which can come before the app renders
import { ThemeProvider } from "./lib/theme";
import "./lib/uiScale"; // applies the chosen interface size before the first paint
import "./index.css";
import "./plugins/plugin-ui.css"; // .wm-* classes for plugin bundles (SPEC §11.3)
+9 -7
View File
@@ -60,6 +60,7 @@ import type { AccountOut, QuotaOut, SavedSearch, ThreadSummary } from "../lib/ty
import { useReadingPane } from "../lib/readingPane";
import { useIsMobile } from "../lib/useIsMobile";
import { useKeyboardShortcuts } from "../lib/useKeyboardShortcuts";
import { uiZoom } from "../lib/uiScale";
import { subscribeWs } from "../lib/ws";
import { PluginSlot } from "../plugins/PluginSlot";
import { usePluginPages } from "../plugins/manifest";
@@ -213,14 +214,15 @@ export function MailLayout() {
if (!el) return;
const measure = () => {
const r = el.getBoundingClientRect();
const z = uiZoom(); // the rect is in screen px; the dock lays out in CSS px
// `right` is the gap to the viewport edge, which is what the dock needs
// to line an expanded window up with the pane — it is non-zero whenever
// the right panel is open. `height` only under the list: everywhere else
// the pane runs the full height of the screen.
setPane({
width: Math.round(r.width),
right: Math.round(window.innerWidth - r.right),
height: bottom ? Math.round(r.height) : undefined,
width: Math.round(r.width / z),
right: Math.round((window.innerWidth - r.right) / z),
height: bottom ? Math.round(r.height / z) : undefined,
});
};
measure();
@@ -240,7 +242,7 @@ export function MailLayout() {
// The list's height when the reading pane sits under it.
// Until dragged, the list takes 45% of the height and the reading pane 55%.
const [listH, setListH] = useState(
() => Number(localStorage.getItem("panel.listH")) || Math.round(window.innerHeight * 0.45),
() => Number(localStorage.getItem("panel.listH")) || Math.round((window.innerHeight / uiZoom()) * 0.45),
);
// Collapsed sidebar shows icons only (persisted). The mobile drawer is always full.
const [collapsedPref, setCollapsed] = useState(
@@ -253,13 +255,13 @@ export function MailLayout() {
const [rightW, setRightW] = useState(() => Number(localStorage.getItem("panel.rightW")) || 340);
const clampSidebar = (w: number) => Math.min(Math.max(w, 180), 420);
// The list can be dragged up to 75% of the window width.
const clampList = (w: number) => Math.min(Math.max(w, 260), window.innerWidth * 0.75);
const clampList = (w: number) => Math.min(Math.max(w, 260), (window.innerWidth / uiZoom()) * 0.75);
const clampRight = (w: number) => Math.min(Math.max(w, 280), 560);
useEffect(() => localStorage.setItem("panel.sidebar", String(sidebarW)), [sidebarW]);
useEffect(() => localStorage.setItem("panel.list", String(listW)), [listW]);
useEffect(() => localStorage.setItem("panel.listH", String(listH)), [listH]);
// Leave both the list and the reading pane at least a usable strip.
const clampListH = (h: number) => Math.min(Math.max(h, 160), window.innerHeight - 220);
const clampListH = (h: number) => Math.min(Math.max(h, 160), window.innerHeight / uiZoom() - 220);
useEffect(
() => localStorage.setItem("panel.collapsed", collapsedPref ? "1" : "0"),
[collapsedPref],
@@ -1166,7 +1168,7 @@ export function MailLayout() {
const paneShown = Boolean(paneDraft || threadId || selectedSub);
return (
<div className={`flex bg-app-bg text-app-fg ${mobile ? "h-[100dvh]" : "h-screen"}`}>
<div className={`flex bg-app-bg text-app-fg ${mobile ? "h-[calc(100dvh/var(--ui-zoom,1))]" : "h-screen"}`}>
<BackfillBanner />
{mobile && (
// Tapping outside the drawer closes it.
+5 -1
View File
@@ -7,6 +7,7 @@ import { RichTextEditor } from "../../components/compose/RichTextEditor";
import { TypedPasswordInput } from "../../components/TypedPasswordInput";
import { ListDividersOption } from "../../components/mail/ListDividersOption";
import { ReadingPanePicker } from "../../components/mail/ReadingPanePicker";
import { UiScaleOption } from "../../components/mail/UiScaleOption";
import { LANGUAGES, translate, useLocale, useT } from "../../i18n";
import type { MessageKey } from "../../i18n/en";
import { api, ApiError } from "../../lib/api";
@@ -190,7 +191,7 @@ function MobileSettings({
};
return (
<div className="relative h-[100dvh] overflow-hidden bg-app-bg text-app-fg">
<div className="relative h-[calc(100dvh/var(--ui-zoom,1))] overflow-hidden bg-app-bg text-app-fg">
<div className="flex h-full flex-col pt-[env(safe-area-inset-top)]">
<header className="flex h-14 shrink-0 items-center gap-2 border-b border-app-border px-2">
<Link to={from} className="flex items-center rounded-lg px-2 py-2 text-app-accent" aria-label={t("nav.back")}>
@@ -1444,6 +1445,9 @@ function AppearanceSection() {
<div className="max-w-md pt-2">
<ListDividersOption />
</div>
<div className="max-w-md pt-2">
<UiScaleOption />
</div>
</div>
<SpellcheckSetting />
<CompletionSetting />
+4
View File
@@ -4,6 +4,10 @@ export default {
content: ["./index.html", "./admin.html", "./src/**/*.{ts,tsx}"],
theme: {
extend: {
// The interface-size setting zooms <html>, and zoom scales viewport units
// too: divide them back so a full-height layout still fits (lib/uiScale.ts).
height: { screen: "calc(100vh / var(--ui-zoom, 1))" },
minHeight: { screen: "calc(100vh / var(--ui-zoom, 1))" },
fontFamily: {
sans: ['"Lato"', "system-ui", "-apple-system", '"Segoe UI"', "Roboto", "sans-serif"],
},