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 @@ -