mirror of
https://github.com/block/buzz.git
synced 2026-10-29 05:21:31 +00:00
## 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>
153 lines
4.0 KiB
TypeScript
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);
|
|
};
|
|
}, []);
|
|
}
|