From 65a3c9c86c4c6d3ab23df89f4398556f026f98ce Mon Sep 17 00:00:00 2001 From: morgmart <98432065+morgmart@users.noreply.github.com> Date: Fri, 21 Aug 2026 13:18:37 -0700 Subject: [PATCH 1/2] fix(desktop): restore true zoom by scaling the root rem MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit PR #5644 introduced a virtual typography rem so the Font size preference could change text without moving layout, but it also routed Cmd +/- zoom through that same px-valued token while pinning the real root at 16px. Zoom became text-only: type grew while rem-based padding, gaps, widths, and controls stayed frozen, producing cramped layouts. Give each dial one owner and let CSS compose them: - Cmd +/- once again scales the real root font-size, so every rem in the app zooms together. - The Font size preference only sets `data-font-size`; typography.css maps it to a unitless `--buzz-type-scale`, mirroring how density works. - `--buzz-type-rem` is now `calc(1rem * var(--buzz-type-scale))` — rem- relative, so it rides on top of zoom automatically. The 13 / 14 / 15px conversation contract is unchanged at default zoom. The preference module no longer does px math or knows about zoom, and the zoom hook no longer imports the preference module. Tests now assert root font-size scales and measure the composed type rem through a rendered probe instead of reading a px literal off the custom property. The inbox spec's zoomed row padding expectation moves from the frozen 4px to the correct 4.4px. Signed-off-by: morgmart <98432065+morgmart@users.noreply.github.com> --- desktop/src/app/useWebviewZoomShortcuts.ts | 21 ++++- .../shared/lib/fontSizePreference.test.mjs | 43 ++++++---- desktop/src/shared/lib/fontSizePreference.ts | 32 ++----- .../src/shared/styles/globals/typography.css | 26 +++++- desktop/tailwind.config.js | 7 +- .../e2e/inbox-refactor-screenshots.spec.ts | 24 ++++-- desktop/tests/e2e/profile.spec.ts | 83 +++++++++---------- .../e2e/top-chrome-zoom-clearance.spec.ts | 22 ++--- 8 files changed, 138 insertions(+), 120 deletions(-) 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/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, From 8555bafa3ef9247ccfbfe6e42c7ab69358e2e0a3 Mon Sep 17 00:00:00 2001 From: morgmart <98432065+morgmart@users.noreply.github.com> Date: Fri, 21 Aug 2026 15:30:03 -0700 Subject: [PATCH 2/2] test(desktop): resolve conversation tokens to px in density/font-size smoke test The appearance smoke test read `--conversation-*` custom properties straight off and asserted on their raw strings (`calc(16px * .875)`, `parseFloat("0.125rem")`). That only worked while `--buzz-type-rem` was a px literal. Now that the type rem is `calc(1rem * --buzz-type-scale)` so Cmd +/- zooms layout and text together, the raw strings are unresolved calc text and the assertions fail deterministically in CI. Resolve every token to px through a probe element (assign the token to the probe's font-size, read the computed value) and assert the 13 / 14 / 15px type contract and comfy / compact / spacious spacing as numbers, independent of each other. This is the same approach profile.spec.ts already uses. Co-Authored-By: Claude Signed-off-by: morgmart <98432065+morgmart@users.noreply.github.com> --- .../tests/e2e/buzz-theme-screenshots.spec.ts | 144 ++++++++++-------- 1 file changed, 80 insertions(+), 64 deletions(-) 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({