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

@@ -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));
});
}
}