fix(workspace): keep the macOS rail clear of the lights when zoomed out

App zoom (webFrame.setZoomLevel) scales CSS pixels but not the native
traffic lights: at the minimum zoom the fixed 56px inset put the first
rail link at about 27 window pixels, over the buttons (y 20 to 34). The
rail now publishes the page zoom factor (outerWidth / innerWidth, refreshed
on resize) and keeps at least 48 window pixels: max(56px, 48px / zoom).

The macOS E2E now requires a native button position instead of falling
back to y=0, measures the gap in window pixels, and repeats it at the
minimum zoom (fails at 27 without this change).

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
4grayandClaude Opus 5.5 committed 2026-10-04 09:47:23 +02:00
1 parent f14d608ce7
commit 51b8928df5
6 files changed
+119 -7

No files matched your search

@@ -217,10 +217,37 @@ test.describe('macOS traffic lights', () => {
const lights = await app.electronApp.evaluate(({ BrowserWindow }) =>
BrowserWindow.getAllWindows()[0]?.getWindowButtonPosition()
);
// The buttons are about 14pt tall; keep a visible gap below them.
expect(linkBox?.y ?? 0).toBeGreaterThanOrEqual(
(lights?.y ?? 0) + 14 + 16
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);
}
+5 -1
View File
@@ -330,7 +330,11 @@ The Electron window hides the native title bar on all desktop platforms
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. `window-controls.e2e.ts` checks both on macOS.
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) {
@@ -32,8 +32,10 @@
// 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: 56px;
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));
});
}
}