Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
21 changes: 17 additions & 4 deletions desktop/src/app/useWebviewZoomShortcuts.ts
Original file line number Diff line number Diff line change
@@ -1,9 +1,14 @@
import * as React from "react";
import { getCurrentWebview } from "@tauri-apps/api/webview";

import { applyTextZoomFactor } from "@/shared/lib/fontSizePreference";
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;
Expand Down Expand Up @@ -75,8 +80,15 @@ function readStoredZoomFactor() {
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) {
applyTextZoomFactor(zoomFactor);
applyRootZoom(zoomFactor);
if (zoomFactor === DEFAULT_ZOOM_FACTOR) {
window.localStorage.removeItem(TEXT_SCALE_STORAGE_KEY);
return;
Expand All @@ -95,7 +107,8 @@ export function useWebviewZoomShortcuts() {
zoomFactorRef.current = storedZoomFactor;
applyTextScale(storedZoomFactor);

// Keep the webview coordinate system stable; only text should scale.
// 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);
});
Expand Down Expand Up @@ -126,7 +139,7 @@ export function useWebviewZoomShortcuts() {

const storedZoomFactor = readStoredZoomFactor();
zoomFactorRef.current = storedZoomFactor;
applyTextZoomFactor(storedZoomFactor);
applyRootZoom(storedZoomFactor);
}

window.addEventListener("keydown", handleKeyDown);
Expand Down
43 changes: 29 additions & 14 deletions desktop/src/shared/lib/fontSizePreference.test.mjs
Original file line number Diff line number Diff line change
@@ -1,15 +1,18 @@
import assert from "node:assert/strict";
import test from "node:test";

import { readFileSync } from "node:fs";

import config from "../../../tailwind.config.js";

const typographyCss = readFileSync(
new URL("../styles/globals/typography.css", import.meta.url),
"utf8",
);

const values = new Map();
const attributes = new Map();
const styleValues = new Map();
const windowListeners = new Map();
const style = {
setProperty: (name, value) => styleValues.set(name, value),
};

globalThis.window = {
addEventListener: (type, listener) => windowListeners.set(type, listener),
Expand All @@ -21,7 +24,6 @@ globalThis.localStorage = {
globalThis.document = {
documentElement: {
setAttribute: (name, value) => attributes.set(name, value),
style,
},
};

Expand All @@ -39,6 +41,28 @@ test("scales fixed line-height utilities with the typography rem", () => {
});
});

test("derives the typography rem from the real root so zoom scales layout too", () => {
// Cmd +/- scales the root font-size; the type rem must stay rem-relative
// (never an absolute px) or text would zoom while containers froze.
assert.match(
typographyCss,
/--buzz-type-rem:\s*calc\(1rem \* var\(--buzz-type-scale\)\);/,
);
assert.doesNotMatch(typographyCss, /--buzz-type-rem:\s*[\d.]+px/);
});

test("maps the font size attribute to the 13 / 14 / 15px type contract", () => {
assert.match(typographyCss, /:root\s*\{[^}]*--buzz-type-scale:\s*1;/s);
assert.match(
typographyCss,
/:root\[data-font-size="smaller"\]\s*\{\s*--buzz-type-scale:\s*calc\(13 \/ 14\);/,
);
assert.match(
typographyCss,
/:root\[data-font-size="larger"\]\s*\{\s*--buzz-type-scale:\s*calc\(15 \/ 14\);/,
);
});

test("defaults invalid and missing font sizes to default", () => {
assert.equal(preference.parseFontSize(null), "default");
assert.equal(preference.parseFontSize("medium"), "default");
Expand All @@ -48,43 +72,35 @@ test("defaults invalid and missing font sizes to default", () => {
});

test("persists and applies the selected font size across the app", () => {
preference.applyTextZoomFactor(1);
preference.setFontSize("smaller");
assert.equal(preference.getFontSize(), "smaller");
assert.equal(values.get(preference.FONT_SIZE_STORAGE_KEY), "smaller");
assert.equal(attributes.get("data-font-size"), "smaller");
assert.equal(styleValues.get("--buzz-type-rem"), "14.857143px");
});

test("previews a font size without changing the saved preference", () => {
preference.applyTextZoomFactor(1.1);
preference.setFontSize("smaller");
preference.previewFontSize("larger");
assert.equal(preference.getFontSize(), "smaller");
assert.equal(values.get(preference.FONT_SIZE_STORAGE_KEY), "smaller");
assert.equal(attributes.get("data-font-size"), "larger");
assert.equal(styleValues.get("--buzz-type-rem"), "18.857143px");

preference.previewFontSize(null);
assert.equal(attributes.get("data-font-size"), "smaller");
assert.equal(styleValues.get("--buzz-type-rem"), "16.342857px");
});

test("initializes from the stored font size", () => {
preference.applyTextZoomFactor(1);
values.set(preference.FONT_SIZE_STORAGE_KEY, "larger");
preference.initializeFontSizePreference();
assert.equal(preference.getFontSize(), "larger");
assert.equal(attributes.get("data-font-size"), "larger");
assert.equal(styleValues.get("--buzz-type-rem"), "17.142857px");
});

test("applies font size changes from another window", () => {
values.set(preference.FONT_SIZE_STORAGE_KEY, "smaller");
windowListeners.get("storage")({ key: preference.FONT_SIZE_STORAGE_KEY });
assert.equal(preference.getFontSize(), "smaller");
assert.equal(attributes.get("data-font-size"), "smaller");
assert.equal(styleValues.get("--buzz-type-rem"), "14.857143px");
});

test("returns to the default when another window clears storage", () => {
Expand All @@ -93,5 +109,4 @@ test("returns to the default when another window clears storage", () => {
windowListeners.get("storage")({ key: null });
assert.equal(preference.getFontSize(), "default");
assert.equal(attributes.get("data-font-size"), "default");
assert.equal(styleValues.get("--buzz-type-rem"), "16px");
});
32 changes: 6 additions & 26 deletions desktop/src/shared/lib/fontSizePreference.ts
Original file line number Diff line number Diff line change
Expand Up @@ -7,20 +7,15 @@ export const FONT_SIZE_STORAGE_KEY = "buzz.appearance.fontSize";
export const DEFAULT_FONT_SIZE: FontSize = "default";

/**
* Virtual rem sizes used by typography tokens. Keeping the real root at 16px
* prevents a text preference from also resizing rem-based layout geometry.
* Root attribute that selects the type scale. The 13 / 14 / 15px contract and
* the virtual typography rem it drives live in `styles/globals/typography.css`;
* this module only records the user's choice. Cmd +/- zoom is a separate dial
* (`useWebviewZoomShortcuts`) that scales the real root font-size.
*/
const TYPE_REM_SIZE_PX: Record<FontSize, number> = {
smaller: 13 / 0.875,
default: 14 / 0.875,
larger: 15 / 0.875,
};

const TYPE_REM_PROPERTY = "--buzz-type-rem";
const FONT_SIZE_ATTRIBUTE = "data-font-size";

const listeners = new Set<() => void>();
let fontSize: FontSize = DEFAULT_FONT_SIZE;
let textZoomFactor = 1;
let listeningForStorageChanges = false;

export function parseFontSize(value: string | null | undefined): FontSize {
Expand All @@ -39,16 +34,8 @@ function readStoredFontSize(): FontSize {
}
}

function typeRemSizePx(size: FontSize): number {
return (
Math.round(TYPE_REM_SIZE_PX[size] * textZoomFactor * 1_000_000) / 1_000_000
);
}

function applyFontSize(size: FontSize): void {
const root = globalThis.document?.documentElement;
root?.setAttribute("data-font-size", size);
root?.style.setProperty(TYPE_REM_PROPERTY, `${typeRemSizePx(size)}px`);
globalThis.document?.documentElement?.setAttribute(FONT_SIZE_ATTRIBUTE, size);
}

function notifyListeners(): void {
Expand Down Expand Up @@ -80,13 +67,6 @@ export function initializeFontSizePreference(): void {
listenForStorageChanges();
}

/** Combine Cmd +/- zoom with the selected app-wide type scale. */
export function applyTextZoomFactor(zoomFactor: number): void {
if (!Number.isFinite(zoomFactor) || zoomFactor <= 0) return;
textZoomFactor = zoomFactor;
applyFontSize(fontSize);
}

function subscribe(listener: () => void): () => void {
listeners.add(listener);
return () => listeners.delete(listener);
Expand Down
26 changes: 22 additions & 4 deletions desktop/src/shared/styles/globals/typography.css
Original file line number Diff line number Diff line change
@@ -1,11 +1,20 @@
@layer base {
:root {
/*
* A virtual typography rem. Font preferences and Cmd +/- change this
* token while the browser root remains 16px, so text scales without also
* resizing rem-based widths, gaps, radii, and controls.
* Two independent dials compose here:
*
* - Cmd +/- zoom scales the real root font-size (see
* useWebviewZoomShortcuts), so every rem in the app — text, gaps,
* widths, radii — grows and shrinks together. True zoom.
* - The Font size preference sets `data-font-size` on the root; it nudges
* only the type scale below, so text changes without moving layout.
*
* Every text token derives from this virtual typography rem, which is
* rem-relative so it rides on top of zoom automatically. At the default
* preference and zoom it equals 16px, making `text-sm` 14px.
*/
--buzz-type-rem: 1rem;
--buzz-type-scale: 1;
--buzz-type-rem: calc(1rem * var(--buzz-type-scale));
--text-xs: calc(var(--buzz-type-rem) * 0.75);
--text-sm: calc(var(--buzz-type-rem) * 0.875);
--text-base: var(--buzz-type-rem);
Expand Down Expand Up @@ -33,6 +42,15 @@
--conversation-timestamp-line-height: var(--buzz-type-rem);
}

/* Conversation text contract: 13 / 14 / 15px before keyboard zoom. */
:root[data-font-size="smaller"] {
--buzz-type-scale: calc(13 / 14);
}

:root[data-font-size="larger"] {
--buzz-type-scale: calc(15 / 14);
}

:root[data-conversation-density="compact"] {
--conversation-body-gap: 0rem;
--conversation-row-padding-block: 0.25rem;
Expand Down
7 changes: 4 additions & 3 deletions desktop/tailwind.config.js
Original file line number Diff line number Diff line change
Expand Up @@ -3,9 +3,10 @@ export default {
theme: {
extend: {
// Sub-`text-xs` ramp for meta text (timestamps, count badges, tracking
// labels) and tiny glyphs. These follow the virtual typography rem so
// preferences and Cmd +/- scale text without changing layout geometry.
// Do NOT reintroduce arbitrary `text-[…rem]` / `text-[…px]` literals;
// labels) and tiny glyphs. These follow the virtual typography rem
// (`--buzz-type-rem` in styles/globals/typography.css), which is
// rem-relative: Cmd +/- zooms it with the rest of the layout, and the
// Font size preference nudges it alone. Do NOT reintroduce arbitrary `text-[…rem]` / `text-[…px]` literals;
// the px-text guard rejects them. Stock scale picks up from xs.
fontSize: {
"2xs": "calc(var(--buzz-type-rem) * 0.6875)", // 11px at 16px type rem
Expand Down
Loading
Loading