From ac971910fd30035189364015c29d7d5842b301a4 Mon Sep 17 00:00:00 2001 From: 4gray Date: Sun, 4 Oct 2026 10:58:42 +0200 Subject: [PATCH] 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 --- .../workspace-header-traffic-light-zoom.md | 6 + .../src/window-controls.e2e.ts | 185 +++++++++++++++--- apps/electron-backend/src/app/app.ts | 3 +- docs/architecture/workspace-shell.md | 34 +++- 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, 511 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..643eb0fbe 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,89 @@ 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, label) + .toBe(control); + await expect + .poll(async () => (await layout()).left, `${label}: first control`) + .toBeGreaterThanOrEqual(lights.x + headerControlOffset); + await expect + .poll(async () => (await layout()).contentTop, `${label}: content top`) + .toBeGreaterThanOrEqual(lights.y + lightsHeight); +} + test.describe('macOS traffic lights', () => { test.skip( process.platform !== 'darwin', @@ -205,49 +294,85 @@ 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; + await addXtreamPortal(page); + await waitForXtreamWorkspaceReady(page); + const lights = await trafficLights(app); + const switcher = 'app-playlist-switcher'; + const back = 'workspace-header-back'; + + await expectHeaderClearOfLights(page, lights, switcher, '100 %'); + await zoomOutFully(page); + await expectHeaderClearOfLights(page, lights, switcher, 'min zoom'); + + // A detail page puts its Back first, pulled 8px toward the edge. + 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 at min zoom' + ); + await resetZoom(page); + await expectHeaderClearOfLights( + page, + lights, + back, + 'detail at 100 %' + ); } finally { await closeElectronApp(app); } diff --git a/apps/electron-backend/src/app/app.ts b/apps/electron-backend/src/app/app.ts index 09b927e6c..cde1417fc 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'; @@ -397,7 +398,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 44f4d2771..cf4c30ef7 100644 --- a/docs/architecture/workspace-shell.md +++ b/docs/architecture/workspace-shell.md @@ -390,15 +390,31 @@ 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 (switcher, 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 @@ -