From 5e5b483dca2dc8ddbad2bf41904e9dd0ed6cbc7b Mon Sep 17 00:00:00 2001 From: 4gray <4gray@users.noreply.github.com> Date: Tue, 6 Oct 2026 19:15:58 +0200 Subject: [PATCH] fix(workspace): keep the macOS header clear of the lights when zoomed out (#1815) * fix(workspace): keep the macOS header clear of the lights when zoomed out App zoom scales CSS pixels but not the native traffic lights. At zoom -3/-4 the header column starts near 29 window pixels, so Back and the playlist switcher slid under the lights, and the 27px header band let the lights overlap the context panel below. A shell-level TrafficLightsClearanceDirective now publishes the lights' clearance in CSS pixels (84 x 48 window pixels, from the page zoom factor) on macOS. The header band grows to the vertical clearance (the rail starts its first link at the same band, replacing the rail's own zoom listener), and the header's leading padding grows to the horizontal clearance less the rail column. Both equal the default layout at 100 %; Windows/Linux and the phone layout are unchanged. The native position is shared with the main process as MACOS_TRAFFIC_LIGHTS_POSITION. Co-Authored-By: Claude Opus 5.5 * test(workspace): pass the header clearance poll labels as options Equivalent to the string form, which Playwright 1.62 also accepts, but explicit in every version (Greptile review). Co-Authored-By: Claude Opus 5.5 * test(workspace): check the header switcher before history exists Since the header's history fallback, a list reached by navigation leads with Back. The macOS check now takes the switcher on the first page, which has nothing to go back to, and Back on a detail page. Co-Authored-By: Claude Opus 5.5 --------- Co-authored-by: 4gray Co-authored-by: Claude Opus 5.5 --- .../workspace-header-traffic-light-zoom.md | 6 + .../src/window-controls.e2e.ts | 192 +++++++++++++++--- apps/electron-backend/src/app/app.ts | 3 +- docs/architecture/workspace-shell.md | 35 +++- libs/shared/interfaces/src/index.ts | 1 + .../src/lib/macos-traffic-lights.ts | 7 + .../workspace-shell-header.component.scss | 10 +- .../workspace-shell-rail.component.html | 6 +- .../workspace-shell-rail.component.scss | 9 +- .../workspace-shell-rail.component.spec.ts | 42 ---- .../workspace-shell-rail.component.ts | 35 +--- ...traffic-lights-clearance.directive.spec.ts | 157 ++++++++++++++ .../traffic-lights-clearance.directive.ts | 100 +++++++++ .../workspace-shell.component.html | 7 +- .../workspace-shell.component.scss | 24 ++- .../workspace-shell.component.spec.ts | 14 ++ .../workspace-shell.component.ts | 2 + 17 files changed, 519 insertions(+), 131 deletions(-) create mode 100644 .changes/workspace-header-traffic-light-zoom.md create mode 100644 libs/shared/interfaces/src/lib/macos-traffic-lights.ts create mode 100644 libs/workspace/shell/feature/src/lib/workspace-shell/traffic-lights-clearance.directive.spec.ts create mode 100644 libs/workspace/shell/feature/src/lib/workspace-shell/traffic-lights-clearance.directive.ts diff --git a/.changes/workspace-header-traffic-light-zoom.md b/.changes/workspace-header-traffic-light-zoom.md new file mode 100644 index 000000000..4263f10ac --- /dev/null +++ b/.changes/workspace-header-traffic-light-zoom.md @@ -0,0 +1,6 @@ +--- +type: fix +area: workspace +--- + +On macOS, zooming the app out no longer slides the Back button and the playlist switcher under the window's close, minimize and zoom buttons; the top bar also stays tall enough that the buttons never overlap the page below. diff --git a/apps/electron-backend-e2e/src/window-controls.e2e.ts b/apps/electron-backend-e2e/src/window-controls.e2e.ts index da90fec62..f60b844fb 100644 --- a/apps/electron-backend-e2e/src/window-controls.e2e.ts +++ b/apps/electron-backend-e2e/src/window-controls.e2e.ts @@ -1,8 +1,14 @@ +import type { Page } from '@playwright/test'; import { + addXtreamPortal, + clickFirstGridListCard, closeElectronApp, expect, launchElectronApp, + LaunchedElectronApp, + resetMockServers, test, + waitForXtreamWorkspaceReady, } from './electron-test-fixtures'; // Custom window controls are only rendered on Windows/Linux; macOS keeps @@ -188,6 +194,93 @@ test.describe('Custom window controls', () => { }); }); +// The native buttons are 14pt circles. At 100 % zoom the first header +// control starts 60pt right of their origin, where macOS 26 ends them +// (earlier releases end them at 52pt). +const lightsHeight = 14; +const headerControlOffset = 60; + +/** Where macOS drew the native window buttons, in window points. */ +async function trafficLights( + app: LaunchedElectronApp +): Promise<{ x: number; y: number }> { + const lights = await app.electronApp.evaluate(({ BrowserWindow }) => + BrowserWindow.getAllWindows()[0]?.getWindowButtonPosition() + ); + expect(lights, 'native window button position').toBeTruthy(); + return lights ?? { x: Number.NaN, y: Number.NaN }; +} + +/** Window pixels per CSS pixel. */ +function zoomFactor(page: Page): Promise { + return page.evaluate(() => window.outerWidth / window.innerWidth); +} + +/** + * Steps the app zoom to its minimum (−4, ≈48 %). App zoom scales CSS pixels + * but not the native buttons. + */ +async function zoomOutFully(page: Page): Promise { + for (let step = 0; step < 8; step++) { + await page.evaluate(() => window.electron.adjustZoomLevel('out')); + } + await expect.poll(() => zoomFactor(page)).toBeLessThan(0.6); +} + +async function resetZoom(page: Page): Promise { + await page.evaluate(() => window.electron.adjustZoomLevel('reset')); + await expect.poll(() => zoomFactor(page)).toBeCloseTo(1, 2); +} + +/** + * The header's first rendered control and the top of the content area, in + * window pixels (CSS pixels times the zoom factor). + */ +function headerLayout( + page: Page +): Promise<{ control?: string; left: number; contentTop: number }> { + return page.locator('.workspace-header').evaluate((header) => { + const zoom = window.outerWidth / window.innerWidth; + const first = [...header.children].find( + (child) => child.getBoundingClientRect().width > 0 + ); + const body = document.querySelector('.workspace-body'); + return { + control: + first?.getAttribute('data-test-id') ?? + first?.tagName.toLowerCase(), + left: (first?.getBoundingClientRect().left ?? 0) * zoom, + contentTop: (body?.getBoundingClientRect().top ?? 0) * zoom, + }; + }); +} + +/** + * The first header control starts right of the lights and the content area + * below them. Polled: the layout follows a zoom change after its resize. + */ +async function expectHeaderClearOfLights( + page: Page, + lights: { x: number; y: number }, + control: string, + label: string +): Promise { + const layout = () => headerLayout(page); + await expect + .poll(async () => (await layout()).control, { message: label }) + .toBe(control); + await expect + .poll(async () => (await layout()).left, { + message: `${label}: first control`, + }) + .toBeGreaterThanOrEqual(lights.x + headerControlOffset); + await expect + .poll(async () => (await layout()).contentTop, { + message: `${label}: content top`, + }) + .toBeGreaterThanOrEqual(lights.y + lightsHeight); +} + test.describe('macOS traffic lights', () => { test.skip( process.platform !== 'darwin', @@ -205,49 +298,88 @@ test.describe('macOS traffic lights', () => { 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 linkOffsetFromContent = async (): Promise => { + const [linkBox, contentBox] = await Promise.all([ + firstLink.boundingBox(), + page.locator('.workspace-content').boundingBox(), + ]); + return Math.abs( + (linkBox?.y ?? 0) - (contentBox?.y ?? Number.NaN) + ); + }; + expect(await linkOffsetFromContent()).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 lights = await trafficLights(app); + // Keep a visible gap below the buttons, 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), + zoomFactor(page), ]); return (box?.y ?? Number.NaN) * zoom; }; expect(await linkTopInWindowPixels()).toBeGreaterThanOrEqual( - lightsY + 14 + 16 + lights.y + lightsHeight + 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); + // At the smallest zoom the inset must still clear the buttons, + // and the link still starts with the content area. + await zoomOutFully(page); await expect .poll(linkTopInWindowPixels) - .toBeGreaterThanOrEqual(lightsY + 14 + 8); - await page.evaluate(() => window.electron.adjustZoomLevel('reset')); + .toBeGreaterThanOrEqual(lights.y + lightsHeight + 8); + await expect.poll(linkOffsetFromContent).toBeLessThanOrEqual(1); + await resetZoom(page); + } finally { + await closeElectronApp(app); + } + }); + + test('@xtream @electron the first header control clears the lights at default and minimum zoom', async ({ + dataDir, + request, + }) => { + await resetMockServers(request, ['xtream']); + const app = await launchElectronApp(dataDir); + + try { + const page = app.mainWindow; + const lights = await trafficLights(app); + const switcher = 'app-playlist-switcher'; + const back = 'workspace-header-back'; + + // The first page has no history to go back to: the switcher leads. + await expectHeaderClearOfLights(page, lights, switcher, 'start'); + await zoomOutFully(page); + await expectHeaderClearOfLights( + page, + lights, + switcher, + 'start at min zoom' + ); + await resetZoom(page); + + // A detail page puts its Back first, pulled 8px toward the edge. + await addXtreamPortal(page); + await waitForXtreamWorkspaceReady(page); + await page + .getByRole('link', { name: 'Series', exact: true }) + .click(); + await clickFirstGridListCard(page); + await expect(page.getByTestId(back)).toBeVisible({ + timeout: 20_000, + }); + await expectHeaderClearOfLights(page, lights, back, 'detail'); + await zoomOutFully(page); + await expectHeaderClearOfLights( + page, + lights, + back, + 'detail at min zoom' + ); + await resetZoom(page); } finally { await closeElectronApp(app); } diff --git a/apps/electron-backend/src/app/app.ts b/apps/electron-backend/src/app/app.ts index 0573c4bba..e60b010c1 100644 --- a/apps/electron-backend/src/app/app.ts +++ b/apps/electron-backend/src/app/app.ts @@ -1,6 +1,7 @@ import { app, BrowserWindow, Menu, screen, session, shell } from 'electron'; import { ElectronBridgeWindowState, + MACOS_TRAFFIC_LIGHTS_POSITION, WINDOW_STATE_CHANGED, } from '@iptvnator/shared/interfaces'; import { join, resolve } from 'path'; @@ -401,7 +402,7 @@ export default class App { return { titleBarStyle: 'hidden', titleBarOverlay: true, - trafficLightPosition: { x: 16, y: 20 }, + trafficLightPosition: { ...MACOS_TRAFFIC_LIGHTS_POSITION }, }; } diff --git a/docs/architecture/workspace-shell.md b/docs/architecture/workspace-shell.md index af2bc2a68..6f6772a0a 100644 --- a/docs/architecture/workspace-shell.md +++ b/docs/architecture/workspace-shell.md @@ -390,15 +390,32 @@ 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. 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. + `trafficLightPosition` from `MACOS_TRAFFIC_LIGHTS_POSITION` in + `@iptvnator/shared/interfaces`); the renderer draws no window buttons. + The lights sit in the header band (`--workspace-header-band`, 56 px) over + the rail and the header's leading padding. The macOS rail + (`.app-rail.is-macos`) starts its first link below the band: level with + the content area and the dashboard hero, with its hover surface clear of + the lights. The header's content starts 84 window pixels from the + window's left edge (60 px rail plus 24 px padding). macOS 26 ends the + lights at 76 (earlier releases at 68), which is where Back's left edge + sits at 100 % because of its 8 px pull-in. + + App zoom (see "Zoom level") scales CSS pixels but not the lights. On + macOS, `TrafficLightsClearanceDirective` on `.workspace-shell` reads the + page zoom factor (`outerWidth / innerWidth`, refreshed on `resize`) and + publishes the clearance in CSS pixels as `--traffic-lights-clear-x` (84 + window pixels) and `--traffic-lights-clear-y` (48: the lights' bottom + plus a gap). Zoomed out, the band grows to the vertical clearance, so the + lights never overlap the content area. The header's leading padding grows + to the horizontal clearance, less the rail column + (`--workspace-header-lights-inset`). At 100 % both match the default + layout. Off macOS nothing is published and the defaults apply. The phone + layout, which puts the rail in a row above the header, ignores the + inset. `window-controls.e2e.ts` ("macOS traffic lights") checks the rail + alignment, the first header control (the switcher on the first page, + which has no history fallback yet, then a detail page's Back) and the + content top at default and minimum zoom. 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/shared/interfaces/src/index.ts b/libs/shared/interfaces/src/index.ts index 95a3a08f2..4b5fedb14 100644 --- a/libs/shared/interfaces/src/index.ts +++ b/libs/shared/interfaces/src/index.ts @@ -9,6 +9,7 @@ export * from './lib/embedded-mpv-session.interface'; export * from './lib/embedded-mpv-support-watch.util'; export * from './lib/subtitle-style.util'; export * from './lib/zoom-level.util'; +export * from './lib/macos-traffic-lights'; export * from './lib/electron-api.interface'; export * from './lib/epg-channel-metadata.model'; export * from './lib/epg-channel-with-programs.interface'; diff --git a/libs/shared/interfaces/src/lib/macos-traffic-lights.ts b/libs/shared/interfaces/src/lib/macos-traffic-lights.ts new file mode 100644 index 000000000..a9fcac3f9 --- /dev/null +++ b/libs/shared/interfaces/src/lib/macos-traffic-lights.ts @@ -0,0 +1,7 @@ +/** + * Where macOS draws the native window buttons (close, minimize, zoom), in + * window points from the window's top-left corner. The main process places + * them (`trafficLightPosition`); the workspace shell keeps its controls clear + * of them at every app zoom, which scales CSS pixels but not the buttons. + */ +export const MACOS_TRAFFIC_LIGHTS_POSITION = { x: 16, y: 20 } as const; diff --git a/libs/workspace/shell/feature/src/lib/workspace-shell/components/workspace-shell-header/workspace-shell-header.component.scss b/libs/workspace/shell/feature/src/lib/workspace-shell/components/workspace-shell-header/workspace-shell-header.component.scss index 169a8561f..6ebbbdc01 100644 --- a/libs/workspace/shell/feature/src/lib/workspace-shell/components/workspace-shell-header/workspace-shell-header.component.scss +++ b/libs/workspace/shell/feature/src/lib/workspace-shell/components/workspace-shell-header/workspace-shell-header.component.scss @@ -7,8 +7,12 @@ display: flex; align-items: center; gap: 12px; - height: 56px; + // The shell's header band: 56px, taller on macOS when zoomed out. + height: 100%; padding: 0 24px; + // macOS: app zoom does not scale the traffic lights, so zoomed out the + // leading controls keep their 100 % distance from them (see the shell). + padding-inline-start: max(24px, var(--workspace-header-lights-inset, 0px)); border-bottom: 1px solid var(--mat-sys-outline-variant); background: var(--app-header-bg, var(--mat-sys-surface-container-low)); app-region: drag; @@ -318,6 +322,10 @@ @media (max-width: 780px) { .workspace-header { padding-inline: 10px; + padding-inline-start: max( + 10px, + var(--workspace-header-lights-inset, 0px) + ); gap: 10px; } 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 13e259f50..04dadb780 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,10 +1,6 @@ -