From 6f247fb5382d783f2df7a01ce150752b7338899d Mon Sep 17 00:00:00 2001 From: 4gray <4gray@users.noreply.github.com> Date: Sun, 4 Oct 2026 11:53:46 +0200 Subject: [PATCH] fix(workspace): start the macOS rail below the traffic lights (#1806) --- .changes/workspace-rail-traffic-light-gap.md | 6 ++ .../src/window-controls.e2e.ts | 66 +++++++++++++++++++ docs/architecture/workspace-shell.md | 10 ++- .../workspace-shell-rail.component.html | 6 +- .../workspace-shell-rail.component.scss | 7 +- .../workspace-shell-rail.component.spec.ts | 42 ++++++++++++ .../workspace-shell-rail.component.ts | 35 +++++++++- 7 files changed, 168 insertions(+), 4 deletions(-) create mode 100644 .changes/workspace-rail-traffic-light-gap.md diff --git a/.changes/workspace-rail-traffic-light-gap.md b/.changes/workspace-rail-traffic-light-gap.md new file mode 100644 index 000000000..dcb03ea74 --- /dev/null +++ b/.changes/workspace-rail-traffic-light-gap.md @@ -0,0 +1,6 @@ +--- +type: fix +area: workspace +--- + +On macOS the side rail now starts level with the page content and the dashboard banner, so its first button no longer crowds the window's close, minimize and zoom buttons. diff --git a/apps/electron-backend-e2e/src/window-controls.e2e.ts b/apps/electron-backend-e2e/src/window-controls.e2e.ts index d65b463bd..da90fec62 100644 --- a/apps/electron-backend-e2e/src/window-controls.e2e.ts +++ b/apps/electron-backend-e2e/src/window-controls.e2e.ts @@ -187,3 +187,69 @@ test.describe('Custom window controls', () => { } }); }); + +test.describe('macOS traffic lights', () => { + test.skip( + process.platform !== 'darwin', + 'Only macOS draws the native traffic lights over the rail' + ); + + test('@electron the first rail link starts with the content area, clear of the lights', async ({ + dataDir, + }) => { + const app = await launchElectronApp(dataDir); + + try { + const page = app.mainWindow; + await expect(page.locator('.app-rail')).toHaveClass(/is-macos/); + const firstLink = page.locator('.app-rail a').first(); + await expect(firstLink).toBeVisible(); + + const [linkBox, contentBox] = await Promise.all([ + firstLink.boundingBox(), + page.locator('.workspace-content').boundingBox(), + ]); + // Aligned with the content area, where the dashboard hero starts. + expect( + Math.abs((linkBox?.y ?? 0) - (contentBox?.y ?? -100)) + ).toBeLessThanOrEqual(1); + + const lights = await app.electronApp.evaluate(({ BrowserWindow }) => + BrowserWindow.getAllWindows()[0]?.getWindowButtonPosition() + ); + expect(lights, 'native window button position').toBeTruthy(); + const lightsY = lights?.y ?? Number.NaN; + // The buttons are about 14pt tall; keep a visible gap below them, + // measured in window pixels (CSS pixels times the zoom factor). + const linkTopInWindowPixels = async (): Promise => { + const [box, zoom] = await Promise.all([ + firstLink.boundingBox(), + page.evaluate(() => window.outerWidth / window.innerWidth), + ]); + return (box?.y ?? Number.NaN) * zoom; + }; + expect(await linkTopInWindowPixels()).toBeGreaterThanOrEqual( + lightsY + 14 + 16 + ); + + // App zoom scales CSS pixels but not the native buttons: at the + // smallest zoom the inset must still clear them. + for (let step = 0; step < 8; step++) { + await page.evaluate(() => + window.electron.adjustZoomLevel('out') + ); + } + await expect + .poll(() => + page.evaluate(() => window.outerWidth / window.innerWidth) + ) + .toBeLessThan(0.6); + await expect + .poll(linkTopInWindowPixels) + .toBeGreaterThanOrEqual(lightsY + 14 + 8); + await page.evaluate(() => window.electron.adjustZoomLevel('reset')); + } finally { + await closeElectronApp(app); + } + }); +}); diff --git a/docs/architecture/workspace-shell.md b/docs/architecture/workspace-shell.md index bf70c49cf..31eaaec18 100644 --- a/docs/architecture/workspace-shell.md +++ b/docs/architecture/workspace-shell.md @@ -326,7 +326,15 @@ The Electron window hides the native title bar on all desktop platforms (`titleBarStyle: 'hidden'` in `apps/electron-backend/src/app/app.ts`): 1. macOS keeps the native traffic lights (`titleBarOverlay: true`, - `trafficLightPosition`); the renderer draws no window buttons. + `trafficLightPosition`); the renderer draws no window buttons. The lights + sit in the 56 px header band above the rail, so the macOS rail + (`.app-rail.is-macos`) starts its first link at 56 px: level with the + content area and the dashboard hero, with its hover surface clear of the + lights. App zoom scales CSS pixels but not the lights, so the rail + publishes the page zoom factor (`outerWidth / innerWidth`, refreshed on + `resize`) as `--rail-zoom-factor` and keeps at least 48 window pixels when + zoomed out. `window-controls.e2e.ts` checks the alignment and the gap at + default and minimum zoom on macOS. 2. Windows and Linux use renderer-drawn window controls (`app-window-controls`, `libs/ui/components/src/lib/window-controls/`). `frame` is intentionally left untouched so native resize borders and diff --git a/libs/workspace/shell/feature/src/lib/workspace-shell/components/workspace-shell-rail/workspace-shell-rail.component.html b/libs/workspace/shell/feature/src/lib/workspace-shell/components/workspace-shell-rail/workspace-shell-rail.component.html index 04dadb780..13e259f50 100644 --- a/libs/workspace/shell/feature/src/lib/workspace-shell/components/workspace-shell-rail/workspace-shell-rail.component.html +++ b/libs/workspace/shell/feature/src/lib/workspace-shell/components/workspace-shell-rail/workspace-shell-rail.component.html @@ -1,6 +1,10 @@ -