Files
buzz/desktop/src/app/useWebviewZoomShortcuts.ts
morgmart 97b1fee5c3 fix(desktop): restore true zoom by scaling the root rem (#6514)
## Summary

Follow-up to #5644. Cmd +/- had become a text-only zoom: type scaled
while rem-based padding, gaps, widths, avatars, and controls stayed
frozen, which produced cramped layouts (see [#buzz-frontend
thread](buzz://message?channel=a410ffde-c61f-416a-96e0-c296b5f5ecc9&id=1a758115cf07b00c097f6e988553908c045165325a57637519cfa7ed9c9accec)).

Root cause: #5644 introduced a virtual typography rem so the **Font
size** preference could change text without moving layout — a good
decoupling — but it also routed **Cmd +/- zoom** through that same
px-valued token and pinned the real root at 16px. One decision ("freeze
layout") was applied to two dials that shouldn't share it.

This PR gives each dial one owner and lets CSS compose them:

| Control | Changes | How |
|---|---|---|
| **Cmd +/- zoom** | Everything — true zoom | Scales the real `<html>`
font-size again (`useWebviewZoomShortcuts`) |
| **Font size preference** | Text only | Sets `data-font-size`;
`typography.css` maps it to a unitless `--buzz-type-scale`, mirroring
how density already works |

`--buzz-type-rem` becomes `calc(1rem * var(--buzz-type-scale))` —
rem-relative, so it rides on zoom automatically. Resulting text px = `16
× zoom × scale × token-ratio`. The 13 / 14 / 15px conversation contract
is unchanged at default zoom. Density and the type ramp from #5644 are
untouched.

The preference module no longer does px math or knows about zoom; the
zoom hook no longer imports the preference module. Net deletion in
production code.

## Validation

- `pnpm test` — 5,308 desktop unit tests
- `pnpm check:px-text`, `tsc --noEmit`, biome
- Playwright: `top-chrome-zoom-clearance.spec.ts` (native-chrome
clearance stays fixed under root zoom),
`inbox-refactor-screenshots.spec.ts` (zoomed row padding now asserts
`4.4px` instead of the frozen `4px`), and both `profile.spec.ts` zoom
tests (composed zoom × preference, cross-window storage reset)
- Before/after screenshots at 140% zoom in the comment below

---------

Signed-off-by: morgmart <98432065+morgmart@users.noreply.github.com>
Co-authored-by: Claude <noreply@anthropic.com>
2026-08-21 17:21:36 -07:00

153 lines
4.0 KiB
TypeScript

import * as React from "react";
import { getCurrentWebview } from "@tauri-apps/api/webview";
import { hasPrimaryShortcutModifier } from "@/shared/lib/platform";
/**
* Cmd +/- scales the real root font-size, so every rem in the app — text,
* spacing, widths, radii — zooms together. The Font size preference is a
* separate, text-only dial layered on top (see `styles/globals/typography.css`).
*/
const BASE_FONT_SIZE_PX = 16;
const DEFAULT_ZOOM_FACTOR = 1;
const MIN_ZOOM_FACTOR = 0.75;
const MAX_ZOOM_FACTOR = 1.5;
const ZOOM_STEP = 0.1;
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 {
if (!hasPrimaryShortcutModifier(event) || event.altKey) {
return null;
}
if (
event.key === "+" ||
event.key === "=" ||
event.code === "Equal" ||
event.code === "NumpadAdd"
) {
return "increase";
}
if (
!event.shiftKey &&
(event.key === "-" ||
event.code === "Minus" ||
event.code === "NumpadSubtract")
) {
return "decrease";
}
if (
!event.shiftKey &&
(event.key === "0" || event.code === "Digit0" || event.code === "Numpad0")
) {
return "reset";
}
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 applyRootZoom(zoomFactor: number) {
document.documentElement.style.fontSize =
zoomFactor === DEFAULT_ZOOM_FACTOR
? ""
: `${BASE_FONT_SIZE_PX * zoomFactor}px`;
}
function applyTextScale(zoomFactor: number) {
applyRootZoom(zoomFactor);
if (zoomFactor === DEFAULT_ZOOM_FACTOR) {
window.localStorage.removeItem(TEXT_SCALE_STORAGE_KEY);
return;
}
window.localStorage.setItem(TEXT_SCALE_STORAGE_KEY, String(zoomFactor));
}
export function useWebviewZoomShortcuts() {
const zoomFactorRef = React.useRef(DEFAULT_ZOOM_FACTOR);
React.useLayoutEffect(() => {
const webview = getCurrentWebview();
const storedZoomFactor = readStoredZoomFactor();
zoomFactorRef.current = storedZoomFactor;
applyTextScale(storedZoomFactor);
// Pin the native webview zoom so the rem root is the only zoom dial and
// window/coordinate math stays stable.
void webview.setZoom(DEFAULT_ZOOM_FACTOR).catch((error) => {
console.error("Failed to reset webview zoom", error);
});
function handleKeyDown(event: KeyboardEvent) {
const action = getZoomAction(event);
if (!action) {
return;
}
event.preventDefault();
const previousZoomFactor = zoomFactorRef.current;
const nextZoomFactor = getNextZoomFactor(action, previousZoomFactor);
if (nextZoomFactor === previousZoomFactor) {
return;
}
zoomFactorRef.current = nextZoomFactor;
applyTextScale(nextZoomFactor);
}
function handleStorage(event: StorageEvent) {
if (event.key !== TEXT_SCALE_STORAGE_KEY && event.key !== null) {
return;
}
const storedZoomFactor = readStoredZoomFactor();
zoomFactorRef.current = storedZoomFactor;
applyRootZoom(storedZoomFactor);
}
window.addEventListener("keydown", handleKeyDown);
window.addEventListener("storage", handleStorage);
return () => {
window.removeEventListener("keydown", handleKeyDown);
window.removeEventListener("storage", handleStorage);
};
}, []);
}