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

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