diff --git a/desktop/src/app/useWebviewZoomShortcuts.ts b/desktop/src/app/useWebviewZoomShortcuts.ts index e8b93207945..3d797b3bb72 100644 --- a/desktop/src/app/useWebviewZoomShortcuts.ts +++ b/desktop/src/app/useWebviewZoomShortcuts.ts @@ -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; @@ -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; @@ -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); }); @@ -126,7 +139,7 @@ export function useWebviewZoomShortcuts() { const storedZoomFactor = readStoredZoomFactor(); zoomFactorRef.current = storedZoomFactor; - applyTextZoomFactor(storedZoomFactor); + applyRootZoom(storedZoomFactor); } window.addEventListener("keydown", handleKeyDown); diff --git a/desktop/src/shared/lib/fontSizePreference.test.mjs b/desktop/src/shared/lib/fontSizePreference.test.mjs index 217a3e5a00a..363a7604d1d 100644 --- a/desktop/src/shared/lib/fontSizePreference.test.mjs +++ b/desktop/src/shared/lib/fontSizePreference.test.mjs @@ -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), @@ -21,7 +24,6 @@ globalThis.localStorage = { globalThis.document = { documentElement: { setAttribute: (name, value) => attributes.set(name, value), - style, }, }; @@ -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"); @@ -48,35 +72,28 @@ 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", () => { @@ -84,7 +101,6 @@ test("applies font size changes from another window", () => { 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", () => { @@ -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"); }); diff --git a/desktop/src/shared/lib/fontSizePreference.ts b/desktop/src/shared/lib/fontSizePreference.ts index 9604a2c2cbc..0db303f00b3 100644 --- a/desktop/src/shared/lib/fontSizePreference.ts +++ b/desktop/src/shared/lib/fontSizePreference.ts @@ -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 = { - 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 { @@ -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 { @@ -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); diff --git a/desktop/src/shared/styles/globals/typography.css b/desktop/src/shared/styles/globals/typography.css index fa59d372411..e25a84afc92 100644 --- a/desktop/src/shared/styles/globals/typography.css +++ b/desktop/src/shared/styles/globals/typography.css @@ -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); @@ -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; diff --git a/desktop/tailwind.config.js b/desktop/tailwind.config.js index 07d00b0db92..4816b3ddc35 100644 --- a/desktop/tailwind.config.js +++ b/desktop/tailwind.config.js @@ -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 diff --git a/desktop/tests/e2e/buzz-theme-screenshots.spec.ts b/desktop/tests/e2e/buzz-theme-screenshots.spec.ts index 408fa851d96..a075c7bd360 100644 --- a/desktop/tests/e2e/buzz-theme-screenshots.spec.ts +++ b/desktop/tests/e2e/buzz-theme-screenshots.spec.ts @@ -676,33 +676,43 @@ test("app font size and conversation density apply independently", async ({ const fontSizeDescription = page .getByTestId("font-size-row") .locator("[data-settings-subcopy]"); + // The conversation tokens are rem-relative `calc(...)` expressions (the + // type tokens ride on `--buzz-type-rem`, which itself derives from the + // root rem so Cmd +/- zooms everything together). Reading the raw custom + // property strings off would just return unresolved calc text, so + // resolve each token to px through a probe element instead: assign the + // token to the probe's font-size and read the computed value back. + // font-size is used (rather than width) because its computed value keeps + // fractional precision instead of snapping to layout units. const readScale = () => root.evaluate((element) => { - const style = window.getComputedStyle(element); - return { - authorLineHeight: Number.parseFloat( - style.getPropertyValue("--conversation-author-line-height"), - ), - bodyGap: Number.parseFloat( - style.getPropertyValue("--conversation-body-gap"), - ), - fontSize: style.getPropertyValue("--conversation-message-font-size"), - lineHeight: style.getPropertyValue( - "--conversation-message-line-height", - ), - paragraphGap: Number.parseFloat( - style.getPropertyValue("--conversation-paragraph-gap"), - ), - rowPadding: Number.parseFloat( - style.getPropertyValue("--conversation-row-padding-block"), - ), - timestampFontSize: style.getPropertyValue( - "--conversation-timestamp-font-size", - ), - timestampLineHeight: Number.parseFloat( - style.getPropertyValue("--conversation-timestamp-line-height"), - ), + const PROBE_ID = "buzz-e2e-conversation-scale-probe"; + const tokens = { + authorLineHeight: "--conversation-author-line-height", + bodyGap: "--conversation-body-gap", + fontSize: "--conversation-message-font-size", + lineHeight: "--conversation-message-line-height", + paragraphGap: "--conversation-paragraph-gap", + rowPadding: "--conversation-row-padding-block", + timestampFontSize: "--conversation-timestamp-font-size", + timestampLineHeight: "--conversation-timestamp-line-height", + } as const; + let probe = element.ownerDocument.getElementById(PROBE_ID); + if (!probe) { + probe = element.ownerDocument.createElement("span"); + probe.id = PROBE_ID; + probe.style.cssText = + "position:absolute;left:-9999px;top:0;visibility:hidden;pointer-events:none"; + element.appendChild(probe); + } + const resolvePx = (token: string) => { + probe.style.fontSize = `var(${token})`; + const px = Number.parseFloat(window.getComputedStyle(probe).fontSize); + return Math.round(px * 100) / 100; }; + return Object.fromEntries( + Object.entries(tokens).map(([key, token]) => [key, resolvePx(token)]), + ) as Record; }); const readSettingsScale = () => page.getByTestId("conversation-density-row").evaluate((element) => { @@ -753,14 +763,15 @@ test("app font size and conversation density apply independently", async ({ await expect(fontSizeDescription).toHaveText( "Applies across conversations and interface text", ); + // Comfy + Default: 14px conversation text on a 16px root rem. await expect.poll(readScale).toEqual({ authorLineHeight: 16, - bodyGap: 0.125, - fontSize: "calc(16px * .875)", - lineHeight: "calc(16px * 1.25)", - paragraphGap: 0.5, - rowPadding: 0.25, - timestampFontSize: "calc(16px * .75)", + bodyGap: 2, + fontSize: 14, + lineHeight: 20, + paragraphGap: 8, + rowPadding: 4, + timestampFontSize: 12, timestampLineHeight: 16, }); await expect @@ -876,14 +887,15 @@ test("app font size and conversation density apply independently", async ({ ), ) .toBe("compact"); + // Compact only tightens spacing; type is untouched. await expect.poll(readScale).toEqual({ authorLineHeight: 16, bodyGap: 0, - fontSize: "calc(16px * .875)", - lineHeight: "calc(16px * 1.25)", - paragraphGap: 0.375, - rowPadding: 0.25, - timestampFontSize: "calc(16px * .75)", + fontSize: 14, + lineHeight: 20, + paragraphGap: 6, + rowPadding: 4, + timestampFontSize: 12, timestampLineHeight: 16, }); await expect.poll(readSettingsScale).toEqual({ @@ -907,15 +919,16 @@ test("app font size and conversation density apply independently", async ({ ), ) .toBe("larger"); + // Larger scales only the type tokens (15/14); compact spacing is unchanged. await expect.poll(readScale).toEqual({ - authorLineHeight: 17.142857, + authorLineHeight: 17.14, bodyGap: 0, - fontSize: "calc(17.142857px * .875)", - lineHeight: "calc(17.142857px * 1.25)", - paragraphGap: 0.375, - rowPadding: 0.25, - timestampFontSize: "calc(17.142857px * .75)", - timestampLineHeight: 17.142857, + fontSize: 15, + lineHeight: 21.43, + paragraphGap: 6, + rowPadding: 4, + timestampFontSize: 12.86, + timestampLineHeight: 17.14, }); await expect .poll(() => @@ -946,15 +959,16 @@ test("app font size and conversation density apply independently", async ({ await expect(root).toHaveAttribute("data-conversation-density", "spacious"); await expect(root).toHaveAttribute("data-font-size", "larger"); await expect(spacious).toHaveAttribute("aria-pressed", "true"); + // Spacious loosens spacing only; Larger type carries over. await expect.poll(readScale).toEqual({ - authorLineHeight: 17.142857, - bodyGap: 0.25, - fontSize: "calc(17.142857px * .875)", - lineHeight: "calc(17.142857px * 1.25)", - paragraphGap: 0.625, - rowPadding: 0.5, - timestampFontSize: "calc(17.142857px * .75)", - timestampLineHeight: 17.142857, + authorLineHeight: 17.14, + bodyGap: 4, + fontSize: 15, + lineHeight: 21.43, + paragraphGap: 10, + rowPadding: 8, + timestampFontSize: 12.86, + timestampLineHeight: 17.14, }); await expect.poll(readSettingsScale).toEqual({ fontSize: "15px", @@ -973,15 +987,16 @@ test("app font size and conversation density apply independently", async ({ await expect(root).toHaveAttribute("data-conversation-density", "spacious"); await expect(root).toHaveAttribute("data-font-size", "smaller"); await expect(smaller).toHaveAttribute("aria-pressed", "true"); + // Smaller scales only the type tokens (13/14); spacious spacing is unchanged. await expect.poll(readScale).toEqual({ - authorLineHeight: 14.857143, - bodyGap: 0.25, - fontSize: "calc(14.857143px * .875)", - lineHeight: "calc(14.857143px * 1.25)", - paragraphGap: 0.625, - rowPadding: 0.5, - timestampFontSize: "calc(14.857143px * .75)", - timestampLineHeight: 14.857143, + authorLineHeight: 14.86, + bodyGap: 4, + fontSize: 13, + lineHeight: 18.57, + paragraphGap: 10, + rowPadding: 8, + timestampFontSize: 11.14, + timestampLineHeight: 14.86, }); await expect .poll(() => @@ -1044,14 +1059,15 @@ test("app font size and conversation density apply independently", async ({ ), ) .toBe("comfortable"); + // Back to Default type while the drag previews spacious spacing. await expect.poll(readScale).toEqual({ authorLineHeight: 16, - bodyGap: 0.25, - fontSize: "calc(16px * .875)", - lineHeight: "calc(16px * 1.25)", - paragraphGap: 0.625, - rowPadding: 0.5, - timestampFontSize: "calc(16px * .75)", + bodyGap: 4, + fontSize: 14, + lineHeight: 20, + paragraphGap: 10, + rowPadding: 8, + timestampFontSize: 12, timestampLineHeight: 16, }); await expect.poll(readSettingsScale).toEqual({ diff --git a/desktop/tests/e2e/inbox-refactor-screenshots.spec.ts b/desktop/tests/e2e/inbox-refactor-screenshots.spec.ts index 8f1f074e277..cc1a8f1e672 100644 --- a/desktop/tests/e2e/inbox-refactor-screenshots.spec.ts +++ b/desktop/tests/e2e/inbox-refactor-screenshots.spec.ts @@ -449,9 +449,14 @@ test.describe("inbox refactor screenshots", () => { if (!header || !body) { throw new Error("Inbox message spacing geometry is missing"); } + // Round to 0.1px: under Cmd +/- zoom the rem gap is fractional and + // layout snaps it to 1/64px. return ( - body.getBoundingClientRect().top - - header.getBoundingClientRect().bottom + Math.round( + (body.getBoundingClientRect().top - + header.getBoundingClientRect().bottom) * + 10, + ) / 10 ); }), selectedAuthor.evaluate((element) => { @@ -558,21 +563,22 @@ test.describe("inbox refactor screenshots", () => { ); }); + // Cmd +/- is a true zoom: the root rem scales, so conversation text AND + // the rem-based row padding / body gap grow together (4px → 4.4px, + // 2px → 2.2px). Text-only zoom with frozen spacing is the regression + // this guards against. await expect .poll(async () => [ - await page.evaluate(() => - window - .getComputedStyle(document.documentElement) - .getPropertyValue("--buzz-type-rem") - .trim(), + await page.evaluate( + () => window.getComputedStyle(document.documentElement).fontSize, ), ...(await readConversationMetrics()), ]) .toEqual([ "17.6px", { fontSize: "15.4px", lineHeight: "22px" }, - { paddingBottom: "4px", paddingTop: "4px" }, - 2, + { paddingBottom: "4.4px", paddingTop: "4.4px" }, + 2.2, { fontSize: "15.4px", lineHeight: "17.6px" }, { fontSize: "15.4px", lineHeight: "22px" }, { fontSize: "13.2px", lineHeight: "17.6px" }, diff --git a/desktop/tests/e2e/profile.spec.ts b/desktop/tests/e2e/profile.spec.ts index 04411720acb..8601f34d241 100644 --- a/desktop/tests/e2e/profile.spec.ts +++ b/desktop/tests/e2e/profile.spec.ts @@ -2490,9 +2490,6 @@ test("supports webview zoom keyboard shortcuts", async ({ page }) => { const getTextScaleState = () => page.evaluate(() => ({ rootFontSize: getComputedStyle(document.documentElement).fontSize, - textRemSize: getComputedStyle(document.documentElement) - .getPropertyValue("--buzz-type-rem") - .trim(), storedScale: localStorage.getItem("buzz:text-scale"), webviewZoom: (window as Window & { __BUZZ_E2E_WEBVIEW_ZOOM__?: number }) .__BUZZ_E2E_WEBVIEW_ZOOM__, @@ -2523,8 +2520,7 @@ test("supports webview zoom keyboard shortcuts", async ({ page }) => { await dispatchPrimaryShortcut("+", "Equal", true); await expect.poll(getTextScaleState).toEqual({ - rootFontSize: "16px", - textRemSize: "17.6px", + rootFontSize: "17.6px", storedScale: "1.1", webviewZoom: 1, }); @@ -2533,7 +2529,6 @@ test("supports webview zoom keyboard shortcuts", async ({ page }) => { await expect.poll(getTextScaleState).toEqual({ rootFontSize: "16px", - textRemSize: "16px", storedScale: null, webviewZoom: 1, }); @@ -2542,8 +2537,7 @@ test("supports webview zoom keyboard shortcuts", async ({ page }) => { await dispatchPrimaryShortcut("+", "Equal", true); await expect.poll(getTextScaleState).toEqual({ - rootFontSize: "16px", - textRemSize: "19.2px", + rootFontSize: "19.2px", storedScale: "1.2", webviewZoom: 1, }); @@ -2552,7 +2546,6 @@ test("supports webview zoom keyboard shortcuts", async ({ page }) => { await expect.poll(getTextScaleState).toEqual({ rootFontSize: "16px", - textRemSize: "16px", storedScale: null, webviewZoom: 1, }); @@ -2586,56 +2579,54 @@ test("storage clear resets composed font size and keyboard zoom across windows", await dispatchZoomIn(); } - await expect - .poll(() => - page.evaluate(() => ({ + // Zoom scales the real root; the Font size preference layers a text-only + // multiplier on top. Resolve the composed type rem through a rendered probe + // so the CSS calc is actually evaluated (root px × 15/14 for "larger"). + const readTypographyState = () => + page.evaluate(() => { + const probe = document.createElement("span"); + probe.style.fontSize = "var(--buzz-type-rem)"; + document.documentElement.appendChild(probe); + const typeRemPx = + Math.round(Number.parseFloat(getComputedStyle(probe).fontSize) * 100) / + 100; + probe.remove(); + return { fontSize: document.documentElement.dataset.fontSize, - textRemSize: getComputedStyle(document.documentElement) - .getPropertyValue("--buzz-type-rem") - .trim(), + rootFontSize: getComputedStyle(document.documentElement).fontSize, + typeRemPx, textScale: localStorage.getItem("buzz:text-scale"), - })), - ) - .toEqual({ - fontSize: "larger", - textRemSize: "25.714286px", - textScale: "1.5", + }; }); + await expect.poll(readTypographyState).toEqual({ + fontSize: "larger", + rootFontSize: "24px", + typeRemPx: 25.71, + textScale: "1.5", + }); + const peerPage = await context.newPage(); await installMockBridge(peerPage); await peerPage.goto("/"); await peerPage.evaluate(() => localStorage.clear()); - await expect - .poll(() => - page.evaluate(() => ({ - fontSize: document.documentElement.dataset.fontSize, - textRemSize: getComputedStyle(document.documentElement) - .getPropertyValue("--buzz-type-rem") - .trim(), - textScale: localStorage.getItem("buzz:text-scale"), - })), - ) - .toEqual({ - fontSize: "default", - textRemSize: "16px", - textScale: null, - }); + await expect.poll(readTypographyState).toEqual({ + fontSize: "default", + rootFontSize: "16px", + typeRemPx: 16, + textScale: null, + }); await page.keyboard.press( process.platform === "darwin" ? "Meta+-" : "Control+-", ); - await expect - .poll(() => - page.evaluate(() => ({ - textRemSize: getComputedStyle(document.documentElement) - .getPropertyValue("--buzz-type-rem") - .trim(), - textScale: localStorage.getItem("buzz:text-scale"), - })), - ) - .toEqual({ textRemSize: "14.4px", textScale: "0.9" }); + await expect.poll(readTypographyState).toEqual({ + fontSize: "default", + rootFontSize: "14.4px", + typeRemPx: 14.4, + textScale: "0.9", + }); await peerPage.close(); }); diff --git a/desktop/tests/e2e/top-chrome-zoom-clearance.spec.ts b/desktop/tests/e2e/top-chrome-zoom-clearance.spec.ts index d26001ba6ef..d1615dbcc64 100644 --- a/desktop/tests/e2e/top-chrome-zoom-clearance.spec.ts +++ b/desktop/tests/e2e/top-chrome-zoom-clearance.spec.ts @@ -90,24 +90,18 @@ async function seedTextScale( }, scale); } -async function expectTextRemSize( +// Cmd +/- scales the real root font-size so every rem in the app zooms. The +// top chrome below must stay fixed *despite* that, which is what these tests +// guard. +async function expectRootFontSize( page: import("@playwright/test").Page, fontSize: string, ) { - await expect - .poll(() => - page.evaluate(() => - getComputedStyle(document.documentElement) - .getPropertyValue("--buzz-type-rem") - .trim(), - ), - ) - .toBe(fontSize); await expect .poll(() => page.evaluate(() => getComputedStyle(document.documentElement).fontSize), ) - .toBe("16px"); + .toBe(fontSize); } test.describe("top chrome macOS traffic-light clearance under text zoom", () => { @@ -152,8 +146,8 @@ test.describe("top chrome macOS traffic-light clearance under text zoom", () => await installMockBridge(page); await page.goto("/"); - // Confirm the zoomed-out text scale applied without changing the root. - await expectTextRemSize(page, "12px"); + // Confirm the zoomed-out scale reached the root. + await expectRootFontSize(page, "12px"); expect(await firstNavButtonX(page)).toBeGreaterThanOrEqual( TRAFFIC_LIGHT_RIGHT_EDGE, @@ -170,7 +164,7 @@ test.describe("top chrome macOS traffic-light clearance under text zoom", () => await installMockBridge(page); await page.goto("/"); - await expectTextRemSize(page, "24px"); + await expectRootFontSize(page, "24px"); expect(await firstNavButtonX(page)).toBeGreaterThanOrEqual( TRAFFIC_LIGHT_RIGHT_EDGE,