diff --git a/desktop/src/app/useWebviewZoomShortcuts.ts b/desktop/src/app/useWebviewZoomShortcuts.ts
index cda6c0f2ed..efed7a7fb2 100644
--- a/desktop/src/app/useWebviewZoomShortcuts.ts
+++ b/desktop/src/app/useWebviewZoomShortcuts.ts
@@ -1,22 +1,15 @@
import * as React from "react";
import { getCurrentWebview } from "@tauri-apps/api/webview";
+import {
+ adjustTextScale,
+ applyCurrentTextScale,
+ DEFAULT_TEXT_SCALE,
+ type TextScaleAction,
+} from "@/shared/lib/textScale";
import { hasPrimaryShortcutModifier } from "@/shared/lib/platform";
-const DEFAULT_ZOOM_FACTOR = 1;
-const MIN_ZOOM_FACTOR = 0.75;
-const MAX_ZOOM_FACTOR = 1.5;
-const ZOOM_STEP = 0.1;
-const BASE_FONT_SIZE_PX = 16;
-const TEXT_SCALE_STORAGE_KEY = "buzz:text-scale";
-
-type ZoomAction = "increase" | "decrease" | "reset";
-
-function roundZoomFactor(zoomFactor: number) {
- return Math.round(zoomFactor * 10) / 10;
-}
-
-function getZoomAction(event: KeyboardEvent): ZoomAction | null {
+function getZoomAction(event: KeyboardEvent): TextScaleAction | null {
if (!hasPrimaryShortcutModifier(event) || event.altKey) {
return null;
}
@@ -49,55 +42,18 @@ function getZoomAction(event: KeyboardEvent): ZoomAction | null {
return null;
}
-function getNextZoomFactor(action: ZoomAction, zoomFactor: number) {
- if (action === "reset") {
- return DEFAULT_ZOOM_FACTOR;
- }
-
- if (action === "increase") {
- return Math.min(roundZoomFactor(zoomFactor + ZOOM_STEP), MAX_ZOOM_FACTOR);
- }
-
- return Math.max(roundZoomFactor(zoomFactor - ZOOM_STEP), MIN_ZOOM_FACTOR);
-}
-
-function readStoredZoomFactor() {
- const raw = window.localStorage.getItem(TEXT_SCALE_STORAGE_KEY);
- if (!raw) {
- return DEFAULT_ZOOM_FACTOR;
- }
-
- const parsed = Number.parseFloat(raw);
- if (!Number.isFinite(parsed)) {
- return DEFAULT_ZOOM_FACTOR;
- }
-
- return Math.min(Math.max(parsed, MIN_ZOOM_FACTOR), MAX_ZOOM_FACTOR);
-}
-
-function applyTextScale(zoomFactor: number) {
- if (zoomFactor === DEFAULT_ZOOM_FACTOR) {
- document.documentElement.style.fontSize = "";
- window.localStorage.removeItem(TEXT_SCALE_STORAGE_KEY);
- return;
- }
-
- document.documentElement.style.fontSize = `${BASE_FONT_SIZE_PX * zoomFactor}px`;
- window.localStorage.setItem(TEXT_SCALE_STORAGE_KEY, String(zoomFactor));
-}
-
+/**
+ * Bootstraps persisted text scale and wires Cmd/Ctrl +/- / 0 shortcuts.
+ * Layout zoom stays on the root font-size; native webview zoom is pinned at 1.
+ */
export function useWebviewZoomShortcuts() {
- const zoomFactorRef = React.useRef(DEFAULT_ZOOM_FACTOR);
-
React.useLayoutEffect(() => {
const webview = getCurrentWebview();
- const storedZoomFactor = readStoredZoomFactor();
- zoomFactorRef.current = storedZoomFactor;
- applyTextScale(storedZoomFactor);
+ applyCurrentTextScale();
// Keep the webview coordinate system stable; only text should scale.
- void webview.setZoom(DEFAULT_ZOOM_FACTOR).catch((error) => {
+ void webview.setZoom(DEFAULT_TEXT_SCALE).catch((error) => {
console.error("Failed to reset webview zoom", error);
});
@@ -108,16 +64,7 @@ export function useWebviewZoomShortcuts() {
}
event.preventDefault();
-
- const previousZoomFactor = zoomFactorRef.current;
- const nextZoomFactor = getNextZoomFactor(action, previousZoomFactor);
-
- if (nextZoomFactor === previousZoomFactor) {
- return;
- }
-
- zoomFactorRef.current = nextZoomFactor;
- applyTextScale(nextZoomFactor);
+ adjustTextScale(action);
}
window.addEventListener("keydown", handleKeyDown);
diff --git a/desktop/src/features/settings/ui/SettingsPanels.tsx b/desktop/src/features/settings/ui/SettingsPanels.tsx
index 5c997efbc4..ed59615ae1 100644
--- a/desktop/src/features/settings/ui/SettingsPanels.tsx
+++ b/desktop/src/features/settings/ui/SettingsPanels.tsx
@@ -38,6 +38,16 @@ import {
type ThreadViewMode,
} from "@/features/channels/lib/threadViewModePreference";
import { cn } from "@/shared/lib/cn";
+import {
+ DEFAULT_TEXT_SCALE,
+ formatTextScalePercent,
+ MAX_TEXT_SCALE,
+ MIN_TEXT_SCALE,
+ setTextScale,
+ TEXT_SCALE_PRESETS,
+ textScalePresetIndex,
+} from "@/shared/lib/textScale";
+import { useTextScale } from "@/shared/lib/useTextScale";
import { Button } from "@/shared/ui/button";
import {
DropdownMenu,
@@ -526,9 +536,11 @@ function ThemeSettingsCard() {
>
+
+
{/* Mode selector: System / Light / Dark */}
{(
@@ -660,6 +672,81 @@ function ThemeSettingsCard() {
);
}
+/**
+ * Interface scale slider. Shares the same text-scale store as Cmd/Ctrl +/-
+ * so keyboard zoom and this control stay in sync.
+ */
+function TextScaleSetting() {
+ const textScale = useTextScale();
+ const isDefault = textScale === DEFAULT_TEXT_SCALE;
+ const presetIndex = textScalePresetIndex(textScale);
+
+ return (
+
+
+
+
Interface scale
+
+ Enlarge or shrink text and UI. Keyboard: Cmd/Ctrl + / − / 0.
+
+
+
+
+ {formatTextScalePercent(MIN_TEXT_SCALE)}
+
+ {
+ const index = Number(event.target.value);
+ const next = TEXT_SCALE_PRESETS[index];
+ if (next != null) {
+ setTextScale(next);
+ }
+ }}
+ step={1}
+ type="range"
+ value={presetIndex}
+ />
+
+ {formatTextScalePercent(MAX_TEXT_SCALE)}
+
+
+ {formatTextScalePercent(textScale)}
+
+
+
+
+
+ );
+}
+
const THREAD_VIEW_MODE_OPTIONS: {
value: ThreadViewMode;
label: string;
diff --git a/desktop/src/shared/lib/textScale.test.mjs b/desktop/src/shared/lib/textScale.test.mjs
new file mode 100644
index 0000000000..634c219d78
--- /dev/null
+++ b/desktop/src/shared/lib/textScale.test.mjs
@@ -0,0 +1,178 @@
+import assert from "node:assert/strict";
+import test from "node:test";
+
+const KEY = "buzz:text-scale";
+let importSequence = 0;
+
+async function withStorage(storage, run) {
+ const descriptor = Object.getOwnPropertyDescriptor(
+ globalThis,
+ "localStorage",
+ );
+ Object.defineProperty(globalThis, "localStorage", {
+ configurable: true,
+ value: storage,
+ });
+ try {
+ const module = await import(`./textScale.ts?test=${importSequence++}`);
+ await run(module);
+ } finally {
+ if (descriptor)
+ Object.defineProperty(globalThis, "localStorage", descriptor);
+ else delete globalThis.localStorage;
+ }
+}
+
+test("missing, malformed, and unreadable preferences default to 1", async () => {
+ for (const stored of [null, "nope", "NaN"]) {
+ await withStorage(
+ { getItem: (key) => (key === KEY ? stored : null), setItem() {} },
+ ({ getTextScale, DEFAULT_TEXT_SCALE }) => {
+ assert.equal(getTextScale(), DEFAULT_TEXT_SCALE);
+ },
+ );
+ }
+
+ await withStorage(
+ {
+ getItem() {
+ throw new Error("storage unavailable");
+ },
+ setItem() {},
+ },
+ ({ getTextScale }) => {
+ assert.equal(getTextScale(), 1);
+ },
+ );
+});
+
+test("loads a stored scale and clamps out-of-range values", async () => {
+ await withStorage(
+ {
+ getItem: (key) => (key === KEY ? "1.2" : null),
+ setItem() {},
+ removeItem() {},
+ },
+ ({ getTextScale }) => {
+ assert.equal(getTextScale(), 1.2);
+ },
+ );
+
+ await withStorage(
+ {
+ getItem: (key) => (key === KEY ? "9" : null),
+ setItem() {},
+ removeItem() {},
+ },
+ ({ getTextScale, MAX_TEXT_SCALE }) => {
+ assert.equal(getTextScale(), MAX_TEXT_SCALE);
+ },
+ );
+
+ await withStorage(
+ {
+ getItem: (key) => (key === KEY ? "0.1" : null),
+ setItem() {},
+ removeItem() {},
+ },
+ ({ getTextScale, MIN_TEXT_SCALE }) => {
+ assert.equal(getTextScale(), MIN_TEXT_SCALE);
+ },
+ );
+});
+
+test("writes non-default scale and removes storage at default", async () => {
+ const writes = [];
+ const removals = [];
+ await withStorage(
+ {
+ getItem: () => null,
+ setItem: (key, value) => writes.push([key, value]),
+ removeItem: (key) => removals.push(key),
+ },
+ ({ setTextScale, getTextScale, DEFAULT_TEXT_SCALE }) => {
+ setTextScale(1.3);
+ assert.equal(getTextScale(), 1.3);
+ assert.deepEqual(writes, [[KEY, "1.3"]]);
+
+ setTextScale(DEFAULT_TEXT_SCALE);
+ assert.equal(getTextScale(), DEFAULT_TEXT_SCALE);
+ assert.deepEqual(removals, [KEY]);
+ },
+ );
+});
+
+test("adjustTextScale steps and resets within bounds", async () => {
+ await withStorage(
+ {
+ getItem: () => null,
+ setItem() {},
+ removeItem() {},
+ },
+ ({
+ adjustTextScale,
+ getTextScale,
+ setTextScale,
+ MIN_TEXT_SCALE,
+ MAX_TEXT_SCALE,
+ DEFAULT_TEXT_SCALE,
+ }) => {
+ setTextScale(1);
+ assert.equal(adjustTextScale("increase"), 1.1);
+ assert.equal(adjustTextScale("decrease"), 1);
+ assert.equal(adjustTextScale("reset"), DEFAULT_TEXT_SCALE);
+
+ setTextScale(MAX_TEXT_SCALE);
+ assert.equal(adjustTextScale("increase"), MAX_TEXT_SCALE);
+
+ setTextScale(MIN_TEXT_SCALE);
+ assert.equal(adjustTextScale("decrease"), MIN_TEXT_SCALE);
+ },
+ );
+});
+
+test("formatTextScalePercent and normalize helpers", async () => {
+ await withStorage(
+ { getItem: () => null, setItem() {}, removeItem() {} },
+ ({
+ formatTextScalePercent,
+ normalizeTextScale,
+ clampTextScale,
+ textScalePresetIndex,
+ TEXT_SCALE_PRESETS,
+ }) => {
+ assert.equal(formatTextScalePercent(1), "100%");
+ // Midpoint 1.25 is equidistant from 1.2 and 1.3; first-best wins → 1.2.
+ assert.equal(formatTextScalePercent(1.25), "120%");
+ assert.equal(normalizeTextScale(1.26), 1.3);
+ assert.equal(normalizeTextScale(1.24), 1.2);
+ assert.equal(normalizeTextScale(0.75), 0.75);
+ assert.equal(clampTextScale(2), 1.5);
+ assert.equal(normalizeTextScale(Number.NaN), 1);
+ assert.equal(textScalePresetIndex(1), TEXT_SCALE_PRESETS.indexOf(1));
+ assert.equal(textScalePresetIndex(0.75), 0);
+ assert.equal(
+ textScalePresetIndex(1.5),
+ TEXT_SCALE_PRESETS.length - 1,
+ );
+ },
+ );
+});
+
+test("keeps the in-memory choice when persistence fails", async () => {
+ await withStorage(
+ {
+ getItem: () => null,
+ setItem() {
+ throw new Error("quota exceeded");
+ },
+ removeItem() {
+ throw new Error("quota exceeded");
+ },
+ },
+ ({ getTextScale, setTextScale }) => {
+ assert.doesNotThrow(() => setTextScale(1.4));
+ assert.equal(getTextScale(), 1.4);
+ },
+ );
+});
diff --git a/desktop/src/shared/lib/textScale.ts b/desktop/src/shared/lib/textScale.ts
new file mode 100644
index 0000000000..7d35bc0f51
--- /dev/null
+++ b/desktop/src/shared/lib/textScale.ts
@@ -0,0 +1,185 @@
+/**
+ * App text / UI scale preference.
+ *
+ * Scales the root `` font-size so rem-based layout grows or shrinks.
+ * Native webview zoom stays pinned at 1 so layout coordinates stay stable
+ * (see `useWebviewZoomShortcuts`).
+ *
+ * Persisted in localStorage under {@link TEXT_SCALE_STORAGE_KEY}. Device-level
+ * only — not community-scoped.
+ *
+ * React hook: {@link useTextScale} in `./useTextScale`.
+ */
+
+export const TEXT_SCALE_STORAGE_KEY = "buzz:text-scale";
+
+export const DEFAULT_TEXT_SCALE = 1;
+export const MIN_TEXT_SCALE = 0.75;
+export const MAX_TEXT_SCALE = 1.5;
+export const TEXT_SCALE_STEP = 0.1;
+
+/**
+ * Discrete levels for the Appearance slider. Matches the keyboard path from
+ * 100% (Cmd/Ctrl +/- steps of 10%) plus the 75% floor reached by clamping.
+ */
+export const TEXT_SCALE_PRESETS = [
+ 0.75, 0.8, 0.9, 1, 1.1, 1.2, 1.3, 1.4, 1.5,
+] as const;
+
+const BASE_FONT_SIZE_PX = 16;
+
+const listeners = new Set<() => void>();
+
+let textScale = readStoredTextScale();
+
+export function roundTextScale(scale: number): number {
+ return Math.round(scale * 10) / 10;
+}
+
+export function clampTextScale(scale: number): number {
+ return Math.min(Math.max(scale, MIN_TEXT_SCALE), MAX_TEXT_SCALE);
+}
+
+/**
+ * Snap a raw scale to the nearest supported preset.
+ *
+ * Note: 0.75 is intentionally supported even though it is not on the 0.1
+ * grid from 1.0 — keyboard zoom reaches it by clamping a stepped 0.7.
+ * Do not round-then-clamp with `Math.round(x * 10) / 10` alone: that turns
+ * 0.75 into 0.8.
+ */
+export function normalizeTextScale(scale: number): number {
+ if (!Number.isFinite(scale)) {
+ return DEFAULT_TEXT_SCALE;
+ }
+
+ let best: number = TEXT_SCALE_PRESETS[0];
+ let bestDistance = Number.POSITIVE_INFINITY;
+ for (const preset of TEXT_SCALE_PRESETS) {
+ const distance = Math.abs(preset - scale);
+ if (distance < bestDistance) {
+ bestDistance = distance;
+ best = preset;
+ }
+ }
+ return best;
+}
+
+function readStoredTextScale(): number {
+ try {
+ const raw = globalThis.localStorage?.getItem(TEXT_SCALE_STORAGE_KEY);
+ if (!raw) {
+ return DEFAULT_TEXT_SCALE;
+ }
+ return normalizeTextScale(Number.parseFloat(raw));
+ } catch {
+ return DEFAULT_TEXT_SCALE;
+ }
+}
+
+function applyTextScaleToDocument(scale: number): void {
+ if (typeof document === "undefined") {
+ return;
+ }
+
+ if (scale === DEFAULT_TEXT_SCALE) {
+ document.documentElement.style.fontSize = "";
+ return;
+ }
+
+ document.documentElement.style.fontSize = `${BASE_FONT_SIZE_PX * scale}px`;
+}
+
+function persistTextScale(scale: number): void {
+ try {
+ if (scale === DEFAULT_TEXT_SCALE) {
+ globalThis.localStorage?.removeItem(TEXT_SCALE_STORAGE_KEY);
+ } else {
+ globalThis.localStorage?.setItem(TEXT_SCALE_STORAGE_KEY, String(scale));
+ }
+ } catch {
+ // Persistence is best-effort; the in-memory value still applies.
+ }
+}
+
+function notify(): void {
+ for (const listener of listeners) {
+ listener();
+ }
+}
+
+/** Read the current text scale outside of React. */
+export function getTextScale(): number {
+ return textScale;
+}
+
+/**
+ * Set text scale, apply it to the document root, persist, and notify
+ * subscribers. Values outside the supported range are clamped.
+ */
+export function setTextScale(scale: number): void {
+ const next = normalizeTextScale(scale);
+ if (next === textScale) {
+ applyTextScaleToDocument(next);
+ return;
+ }
+
+ textScale = next;
+ applyTextScaleToDocument(next);
+ persistTextScale(next);
+ notify();
+}
+
+export type TextScaleAction = "increase" | "decrease" | "reset";
+
+/** Step text scale up/down or reset to default (same steps as keyboard zoom). */
+export function adjustTextScale(action: TextScaleAction): number {
+ if (action === "reset") {
+ setTextScale(DEFAULT_TEXT_SCALE);
+ return textScale;
+ }
+
+ if (action === "increase") {
+ setTextScale(textScale + TEXT_SCALE_STEP);
+ return textScale;
+ }
+
+ setTextScale(textScale - TEXT_SCALE_STEP);
+ return textScale;
+}
+
+/**
+ * Apply the current in-memory scale to the document (e.g. on app boot after
+ * module load, or after restoring from storage in a new document).
+ */
+export function applyCurrentTextScale(): void {
+ applyTextScaleToDocument(textScale);
+}
+
+export function subscribeTextScale(listener: () => void): () => void {
+ listeners.add(listener);
+ return () => {
+ listeners.delete(listener);
+ };
+}
+
+export function getTextScaleSnapshot(): number {
+ return textScale;
+}
+
+export function getTextScaleServerSnapshot(): number {
+ return DEFAULT_TEXT_SCALE;
+}
+
+/** Format a scale factor as a percentage label (e.g. 1.2 → "120%"). */
+export function formatTextScalePercent(scale: number): string {
+ return `${Math.round(normalizeTextScale(scale) * 100)}%`;
+}
+
+/** Nearest index into {@link TEXT_SCALE_PRESETS} for a scale factor. */
+export function textScalePresetIndex(scale: number): number {
+ const target = normalizeTextScale(scale);
+ return TEXT_SCALE_PRESETS.indexOf(
+ target as (typeof TEXT_SCALE_PRESETS)[number],
+ );
+}
diff --git a/desktop/src/shared/lib/useTextScale.ts b/desktop/src/shared/lib/useTextScale.ts
new file mode 100644
index 0000000000..0a8d8f12ae
--- /dev/null
+++ b/desktop/src/shared/lib/useTextScale.ts
@@ -0,0 +1,16 @@
+import * as React from "react";
+
+import {
+ getTextScaleServerSnapshot,
+ getTextScaleSnapshot,
+ subscribeTextScale,
+} from "@/shared/lib/textScale";
+
+/** React hook for the current text / UI scale factor. */
+export function useTextScale(): number {
+ return React.useSyncExternalStore(
+ subscribeTextScale,
+ getTextScaleSnapshot,
+ getTextScaleServerSnapshot,
+ );
+}