fix(workspace): start the macOS rail below the traffic lights (#1806)

This commit is contained in:
4gray authored and GitHub committed 2026-10-04 11:53:46 +02:00
1 parent 7fd3d1dab0
commit 6f247fb538
7 files changed
+168 -4

No files matched your search

@@ -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.
@@ -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<number> => {
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);
}
});
});
+9 -1
View File
@@ -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
@@ -1,6 +1,10 @@
<!-- No brand mark: it linked to the page the first workspace link opens.
The page's Back lives in the header's leading slot. -->
<aside class="app-rail" [class.is-macos]="isMacOS()">
<aside
class="app-rail"
[class.is-macos]="isMacOS()"
[style.--rail-zoom-factor]="isMacOS() ? zoomFactor() : null"
>
<app-workspace-shell-rail-links [links]="workspaceLinks()" />
@if (primaryContextLinks().length > 0) {
@@ -29,8 +29,13 @@
--rail-link-active-border: var(--app-selection-border);
--rail-link-active-glow: var(--app-selection-glow);
// The traffic lights sit in the header band (56px, see the shell grid).
// The first link starts below that band, where the content area and the
// dashboard hero start, so its hover surface never touches the lights.
// App zoom scales CSS pixels but not the lights: zoomed out, the inset
// keeps 48 window pixels (lights' bottom plus a gap) instead.
&.is-macos {
padding-top: 36px;
padding-top: max(56px, calc(48px / var(--rail-zoom-factor, 1)));
}
&::-webkit-scrollbar {
@@ -84,6 +84,48 @@ describe('WorkspaceShellRailComponent', () => {
).not.toBeNull();
});
describe('macOS zoom inset', () => {
const setWindowWidths = (outer: number, inner: number) => {
Object.defineProperty(window, 'outerWidth', {
configurable: true,
value: outer,
});
Object.defineProperty(window, 'innerWidth', {
configurable: true,
value: inner,
});
};
const zoomVar = () =>
(
fixture.nativeElement.querySelector('.app-rail') as HTMLElement
).style.getPropertyValue('--rail-zoom-factor');
afterEach(() => setWindowWidths(1024, 1024));
it('publishes the page zoom factor on macOS and follows resize', () => {
setWindowWidths(1200, 1200);
fixture.componentRef.setInput('isMacOS', true);
fixture.detectChanges();
TestBed.tick();
fixture.detectChanges();
expect(zoomVar()).toBe('1');
// Zoomed out to 50%: the viewport holds twice the CSS pixels.
setWindowWidths(1200, 2400);
window.dispatchEvent(new Event('resize'));
fixture.detectChanges();
expect(zoomVar()).toBe('0.5');
});
it('leaves the inset alone off macOS', () => {
setWindowWidths(1200, 2400);
fixture.detectChanges();
TestBed.tick();
fixture.detectChanges();
expect(zoomVar()).toBe('');
});
});
it('has no brand mark duplicating the first workspace link', () => {
fixture.detectChanges();
@@ -1,4 +1,12 @@
import { ChangeDetectionStrategy, Component, input } from '@angular/core';
import { DOCUMENT } from '@angular/common';
import {
ChangeDetectionStrategy,
Component,
effect,
inject,
input,
signal,
} from '@angular/core';
import { MatIcon } from '@angular/material/icon';
import { MatTooltip } from '@angular/material/tooltip';
import { RouterLink } from '@angular/router';
@@ -32,4 +40,29 @@ export class WorkspaceShellRailComponent {
>(null);
readonly railProviderClass = input('rail-context-region');
readonly isSettingsRoute = input(false);
/**
* The page zoom factor. App zoom (`webFrame.setZoomLevel`) scales CSS
* pixels but not the native traffic lights, so the macOS top inset is
* kept in window pixels when zoomed out. Chromium reports the window in
* window pixels and the viewport in CSS pixels, and fires `resize` when
* the zoom changes.
*/
protected readonly zoomFactor = signal(1);
constructor() {
const view = inject(DOCUMENT).defaultView;
effect((onCleanup) => {
if (!this.isMacOS() || !view) return;
const update = () =>
this.zoomFactor.set(
view.innerWidth > 0 && view.outerWidth > 0
? view.outerWidth / view.innerWidth
: 1
);
update();
view.addEventListener('resize', update);
onCleanup(() => view.removeEventListener('resize', update));
});
}
}