diff --git a/desktop/playwright.config.ts b/desktop/playwright.config.ts index 3b5f35d990..45d8fa0096 100644 --- a/desktop/playwright.config.ts +++ b/desktop/playwright.config.ts @@ -50,6 +50,7 @@ export default defineConfig({ "**/unread-pill.spec.ts", "**/sidebar-more-unread-overlap.spec.ts", "**/home-collapsed-top-chrome.spec.ts", + "**/top-chrome-zoom-clearance.spec.ts", "**/thread-unread.spec.ts", "**/workspace-rail.spec.ts", "**/thread-reply-anchor-roleplay.spec.ts", diff --git a/desktop/src/app/AppTopChrome.tsx b/desktop/src/app/AppTopChrome.tsx index 2be7f2b46b..0f00205340 100644 --- a/desktop/src/app/AppTopChrome.tsx +++ b/desktop/src/app/AppTopChrome.tsx @@ -21,10 +21,14 @@ type AppTopChromeProps = { hasWorkspaceRail?: boolean; }; +// Fixed px on purpose (button box + glyph): these controls sit beside the +// native macOS traffic lights, which ignore the app's Cmd +/- text zoom, so +// the row must not grow or shrink with the rem scale. Deliberate exception +// to the rem-first rule. const TOP_CHROME_ICON_BUTTON_CLASS = - "h-7 w-7 rounded-[4px] text-sidebar-foreground/65 hover:bg-sidebar-accent hover:text-sidebar-accent-foreground [&_svg]:size-4"; + "h-[28px] w-[28px] rounded-[4px] text-sidebar-foreground/65 hover:bg-sidebar-accent hover:text-sidebar-accent-foreground [&_svg]:size-[16px]"; const HISTORY_ICON_BUTTON_CLASS = - "h-7 w-6 rounded-[4px] text-sidebar-foreground/65 hover:bg-sidebar-accent hover:text-sidebar-accent-foreground [&_svg]:size-4"; + "h-[28px] w-[24px] rounded-[4px] text-sidebar-foreground/65 hover:bg-sidebar-accent hover:text-sidebar-accent-foreground [&_svg]:size-[16px]"; function preventTopChromeWheel(event: WheelEvent) { event.preventDefault(); @@ -66,11 +70,15 @@ export function AppTopChrome({ // x-position. When the workspace rail is present it already occupies the far // left, so the nav row only needs to clear the lights past the rail edge // rather than the full offset. In fullscreen those buttons hide. + // + // Fixed px on purpose: the native traffic lights do not scale with the app's + // Cmd +/- text zoom (rem), so rem-based clearance shrinks under them when + // zoomed out. This is a deliberate exception to the rem-first rule. const macChrome = isMacPlatform() && !isFullscreen; const navRowPaddingClass = macChrome ? hasWorkspaceRail - ? "pl-8" - : "pl-20" + ? "pl-[32px]" + : "pl-[80px]" : "pl-3"; const navRowAlignmentClass = macChrome ? "translate-y-[3px]" : null; diff --git a/desktop/src/features/sidebar/ui/WorkspaceRail.tsx b/desktop/src/features/sidebar/ui/WorkspaceRail.tsx index 7f6f3bdcc4..4911514ac1 100644 --- a/desktop/src/features/sidebar/ui/WorkspaceRail.tsx +++ b/desktop/src/features/sidebar/ui/WorkspaceRail.tsx @@ -141,7 +141,7 @@ export function WorkspaceRail({ // macOS traffic lights overlay the top-left, so start buttons below them (they hide in fullscreen). const topPaddingClass = isMacPlatform() && !isFullscreen - ? "pt-(--buzz-top-chrome-height,2.5rem)" + ? "pt-(--buzz-top-chrome-height,40px)" : "pt-3"; return ( diff --git a/desktop/src/shared/layout/chromeLayout.ts b/desktop/src/shared/layout/chromeLayout.ts index 82ebdbb4e1..5ae0f2f1f6 100644 --- a/desktop/src/shared/layout/chromeLayout.ts +++ b/desktop/src/shared/layout/chromeLayout.ts @@ -1,4 +1,8 @@ -export const TOP_CHROME_HEIGHT_DEFAULT = "2.5rem"; +// Fixed px on purpose: the top chrome strip hosts the fixed-size macOS +// traffic lights and the px-pinned nav buttons (see `AppTopChrome`), so its +// height must not follow the Cmd +/- rem text scale either. Deliberate +// exception to the rem-first rule. 40px == the old 2.5rem at default zoom. +export const TOP_CHROME_HEIGHT_DEFAULT = "40px"; export const CHANNEL_CONTENT_TOP_PADDING_DEFAULT = "5.75rem"; export const chromeCssVars = { @@ -38,9 +42,9 @@ export const topChromeInset = { /** Tailwind class fragments for the global top chrome backdrop strip. */ export const topChromeBackdrop = { /** Height matching the global top chrome search/drag strip. */ - height: "h-(--buzz-top-chrome-height,2.5rem)", + height: "h-(--buzz-top-chrome-height,40px)", /** `after:` pseudo-element offset aligned to the bottom of top chrome. */ - dividerTop: "after:top-(--buzz-top-chrome-height,2.5rem)", + dividerTop: "after:top-(--buzz-top-chrome-height,40px)", } as const; /** Tailwind class fragments for measured channel header chrome. */ diff --git a/desktop/tests/e2e/top-chrome-zoom-clearance.spec.ts b/desktop/tests/e2e/top-chrome-zoom-clearance.spec.ts new file mode 100644 index 0000000000..30a9b729af --- /dev/null +++ b/desktop/tests/e2e/top-chrome-zoom-clearance.spec.ts @@ -0,0 +1,138 @@ +import { expect, test } from "@playwright/test"; + +import { installMockBridge } from "../helpers/bridge"; + +// The macOS traffic lights are native chrome: with `trafficLightPosition` +// x:16 they occupy roughly x 16–68 regardless of the app's Cmd +/- text +// zoom. The top-chrome nav row must clear that band in fixed px, so the +// clearance cannot shrink when the root font size scales down. +const TRAFFIC_LIGHT_RIGHT_EDGE = 72; + +async function spoofMacPlatform(page: import("@playwright/test").Page) { + await page.addInitScript(() => { + Object.defineProperty(navigator, "platform", { get: () => "MacIntel" }); + }); +} + +async function firstNavButtonX(page: import("@playwright/test").Page) { + const toggle = page.locator('[data-testid="app-top-chrome"] button').first(); + await expect(toggle).toBeVisible(); + const box = await toggle.boundingBox(); + expect(box).not.toBeNull(); + return box?.x ?? 0; +} + +// The chrome buttons are styled to visually match the fixed-size native +// controls, so their box must not follow the rem text scale either. The +// sidebar toggle is 28px square; the back/forward history buttons share the +// height but are deliberately narrower (24px). +const NAV_BUTTON_SIZE = 28; +const HISTORY_BUTTON_WIDTH = 24; + +// The grabber/drag strip hosting the buttons must hold its height too — +// otherwise Cmd+ balloons the bar around the fixed-size buttons and Cmd- +// collapses it. +const TOP_CHROME_BAR_HEIGHT = 40; + +async function expectTopChromeFixedHeight( + page: import("@playwright/test").Page, +) { + const bar = page.getByTestId("app-top-chrome"); + await expect(bar).toBeVisible(); + const box = await bar.boundingBox(); + expect(box).not.toBeNull(); + expect(box?.height ?? 0).toBe(TOP_CHROME_BAR_HEIGHT); +} + +async function expectNavButtonsFixedSize( + page: import("@playwright/test").Page, +) { + const buttons = page.locator('[data-testid="app-top-chrome"] button'); + const count = await buttons.count(); + expect(count).toBeGreaterThan(0); + for (let i = 0; i < count; i += 1) { + const button = buttons.nth(i); + const label = await button.getAttribute("aria-label"); + const isHistoryButton = label === "Go back" || label === "Go forward"; + const box = await button.boundingBox(); + expect(box).not.toBeNull(); + expect(box?.width ?? 0).toBe( + isHistoryButton ? HISTORY_BUTTON_WIDTH : NAV_BUTTON_SIZE, + ); + expect(box?.height ?? 0).toBe(NAV_BUTTON_SIZE); + } +} + +async function seedTextScale( + page: import("@playwright/test").Page, + scale: number, +) { + await page.addInitScript((value) => { + window.localStorage.setItem("buzz:text-scale", String(value)); + }, scale); +} + +async function expectRootFontSize( + page: import("@playwright/test").Page, + fontSize: string, +) { + await expect + .poll(() => + page.evaluate(() => getComputedStyle(document.documentElement).fontSize), + ) + .toBe(fontSize); +} + +test.describe("top chrome macOS traffic-light clearance under text zoom", () => { + test("nav buttons clear the traffic lights at default zoom", async ({ + page, + }) => { + await spoofMacPlatform(page); + await installMockBridge(page); + await page.goto("/"); + + expect(await firstNavButtonX(page)).toBeGreaterThanOrEqual( + TRAFFIC_LIGHT_RIGHT_EDGE, + ); + await expectNavButtonsFixedSize(page); + await expectTopChromeFixedHeight(page); + }); + + test("nav buttons still clear the traffic lights when zoomed out", async ({ + page, + }) => { + await spoofMacPlatform(page); + // Seed the minimum Cmd- text scale (0.75). The old rem-based clearance + // (pl-20 = 5rem) shrank to 60px here, sliding the buttons under the + // fixed-position native controls. + await seedTextScale(page, 0.75); + await installMockBridge(page); + await page.goto("/"); + + // Confirm the zoomed-out scale actually applied to the root font size. + await expectRootFontSize(page, "12px"); + + expect(await firstNavButtonX(page)).toBeGreaterThanOrEqual( + TRAFFIC_LIGHT_RIGHT_EDGE, + ); + await expectNavButtonsFixedSize(page); + await expectTopChromeFixedHeight(page); + }); + + test("nav buttons keep their fixed size when zoomed in", async ({ page }) => { + await spoofMacPlatform(page); + // Seed the maximum Cmd+ text scale (1.5). Rem-sized buttons (h-7 = 42px + // here) grew visibly taller than the fixed-size traffic lights. + await seedTextScale(page, 1.5); + await installMockBridge(page); + await page.goto("/"); + + await expectRootFontSize(page, "24px"); + + expect(await firstNavButtonX(page)).toBeGreaterThanOrEqual( + TRAFFIC_LIGHT_RIGHT_EDGE, + ); + await expectNavButtonsFixedSize(page); + await expectTopChromeFixedHeight(page); + }); +});