diff --git a/frontend/src/components/Resizer.tsx b/frontend/src/components/Resizer.tsx index 57d595a..02c2c2e 100644 --- a/frontend/src/components/Resizer.tsx +++ b/frontend/src/components/Resizer.tsx @@ -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 = () => { diff --git a/frontend/src/components/compose/ComposeDock.tsx b/frontend/src/components/compose/ComposeDock.tsx index e49a508..d6c23d9 100644 --- a/frontend/src/components/compose/ComposeDock.tsx +++ b/frontend/src/components/compose/ComposeDock.tsx @@ -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()); diff --git a/frontend/src/components/compose/EmojiButton.tsx b/frontend/src/components/compose/EmojiButton.tsx index 67cafb2..042f5b4 100644 --- a/frontend/src/components/compose/EmojiButton.tsx +++ b/frontend/src/components/compose/EmojiButton.tsx @@ -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), }); }; diff --git a/frontend/src/components/compose/RichTextEditor.tsx b/frontend/src/components/compose/RichTextEditor.tsx index bbad126..e559728 100644 --- a/frontend/src/components/compose/RichTextEditor.tsx +++ b/frontend/src/components/compose/RichTextEditor.tsx @@ -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]); diff --git a/frontend/src/components/list/MessageList.tsx b/frontend/src/components/list/MessageList.tsx index 2b0337d..7a841cd 100644 --- a/frontend/src/components/list/MessageList.tsx +++ b/frontend/src/components/list/MessageList.tsx @@ -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) => { 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(() => { diff --git a/frontend/src/components/mail/MailboxDisplay.tsx b/frontend/src/components/mail/MailboxDisplay.tsx index ba7b001..06abc21 100644 --- a/frontend/src/components/mail/MailboxDisplay.tsx +++ b/frontend/src/components/mail/MailboxDisplay.tsx @@ -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({ <>

{t("display.readingPane")}

-
+
+
) : ( - +
+ + +
)}
); diff --git a/frontend/src/components/mail/UiScaleOption.tsx b/frontend/src/components/mail/UiScaleOption.tsx new file mode 100644 index 0000000..19e8483 --- /dev/null +++ b/frontend/src/components/mail/UiScaleOption.tsx @@ -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 ( +
+ {t("display.size")} +
+ {UI_SCALES.map((value) => ( + + ))} +
+
+ ); +} diff --git a/frontend/src/i18n/en.ts b/frontend/src/i18n/en.ts index ac156e8..3f388dc 100644 --- a/frontend/src/i18n/en.ts +++ b/frontend/src/i18n/en.ts @@ -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.", diff --git a/frontend/src/i18n/pl.ts b/frontend/src/i18n/pl.ts index 1559580..14bc1ae 100644 --- a/frontend/src/i18n/pl.ts +++ b/frontend/src/i18n/pl.ts @@ -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.", diff --git a/frontend/src/lib/uiScale.ts b/frontend/src/lib/uiScale.ts new file mode 100644 index 0000000..28177fd --- /dev/null +++ b/frontend/src/lib/uiScale.ts @@ -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 , 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); diff --git a/frontend/src/main.tsx b/frontend/src/main.tsx index 399c6ee..d9bdd2e 100644 --- a/frontend/src/main.tsx +++ b/frontend/src/main.tsx @@ -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) diff --git a/frontend/src/routes/MailLayout.tsx b/frontend/src/routes/MailLayout.tsx index 51d6059..d347535 100644 --- a/frontend/src/routes/MailLayout.tsx +++ b/frontend/src/routes/MailLayout.tsx @@ -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 ( -
+
{mobile && ( // Tapping outside the drawer closes it. diff --git a/frontend/src/routes/settings/Settings.tsx b/frontend/src/routes/settings/Settings.tsx index 6a86b8e..10a5d9d 100644 --- a/frontend/src/routes/settings/Settings.tsx +++ b/frontend/src/routes/settings/Settings.tsx @@ -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 ( -
+
@@ -1444,6 +1445,9 @@ function AppearanceSection() {
+
+ +
diff --git a/frontend/tailwind.config.js b/frontend/tailwind.config.js index 4c779cb..4feb26b 100644 --- a/frontend/tailwind.config.js +++ b/frontend/tailwind.config.js @@ -4,6 +4,10 @@ export default { content: ["./index.html", "./admin.html", "./src/**/*.{ts,tsx}"], theme: { extend: { + // The interface-size setting zooms , 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"], },