From 3f4a1365f8e18e6d22a2a4ac0ed290a941bb6012 Mon Sep 17 00:00:00 2001 From: Linus Rath <139418639+rathlinus@users.noreply.github.com> Date: Wed, 4 Nov 2026 11:11:36 +0100 Subject: [PATCH] fix: keep the window's buttons clear of the app's after a maximise, restore, snap or resize --- lib/__tests__/window-controls-host.test.tsx | 26 +++++++++++++++++++++ lib/window-controls-overlay.ts | 17 ++++++++++---- 2 files changed, 39 insertions(+), 4 deletions(-) diff --git a/lib/__tests__/window-controls-host.test.tsx b/lib/__tests__/window-controls-host.test.tsx index ee7075b60..77599c97e 100644 --- a/lib/__tests__/window-controls-host.test.tsx +++ b/lib/__tests__/window-controls-host.test.tsx @@ -348,6 +348,32 @@ describe('the window controls over the app', () => { expect(root.hasAttribute(CONTROLS_ATTRIBUTE)).toBe(false); expect(rightInset()).toBe(''); }); + + describe('when the window changes size in one step', () => { + const startWidth = window.innerWidth; + const setWidth = (width: number) => Object.defineProperty(window, 'innerWidth', { value: width, configurable: true, writable: true }); + afterEach(() => setWidth(startWidth)); + + // As Chromium does it: geometrychange with the new rect while the page still has + // its old width, then the page takes the new width and fires resize. + const resizeTo = (fake: EventTarget, width: number) => act(() => { + (fake as unknown as { getTitlebarAreaRect: () => object }).getTitlebarAreaRect = () => ({ x: 0, y: 0, width: width - 137, height: 56 }); + fake.dispatchEvent(new Event('geometrychange')); + setWidth(width); + window.dispatchEvent(new Event('resize')); + }); + + it('keeps the room for the controls after a maximise, a restore, a snap and a shrink', () => { + setWidth(1366); + const fake = overlayAt(0, 1366 - 137); + render(); + expect(rightInset()).toBe('137px'); + for (const width of [2048, 1366, 1024, 640, 1920]) { + resizeTo(fake, width); + expect(rightInset(), `at ${width} px`).toBe('137px'); + } + }); + }); }); describe('which rows hold the controls', () => { diff --git a/lib/window-controls-overlay.ts b/lib/window-controls-overlay.ts index d679f023f..100434fba 100644 --- a/lib/window-controls-overlay.ts +++ b/lib/window-controls-overlay.ts @@ -94,9 +94,14 @@ export function applyControlsArea(area: TitlebarArea | null, root: HTMLElement = /** * Calls `listener` now and whenever the overlay's size or visibility changes - * (`geometrychange`). Without the API it calls it once with null and listens - * to nothing, so a browser without it runs no extra code. Returns the - * unsubscribe. + * (`geometrychange`), and again when the page's width does (`resize`). + * Without the API it calls it once with null and listens to nothing, so a + * browser without it runs no extra code. Returns the unsubscribe. + * + * `geometrychange` comes before the page has its new width: after a maximise, + * restore, snap or drag the rect is the new one and innerWidth still the old, + * so the room worked out from the two is wrong (none at all after growing, more + * than the window after shrinking). The `resize` that follows has both. */ export function watchTitlebarArea(listener: (area: TitlebarArea | null) => void): () => void { listener(readTitlebarArea()); @@ -104,7 +109,11 @@ export function watchTitlebarArea(listener: (area: TitlebarArea | null) => void) if (!current) return () => {}; const onChange = () => listener(readTitlebarArea()); current.addEventListener('geometrychange', onChange); - return () => current.removeEventListener('geometrychange', onChange); + window.addEventListener('resize', onChange); + return () => { + current.removeEventListener('geometrychange', onChange); + window.removeEventListener('resize', onChange); + }; } const ROW_CANDIDATES = '[data-window-top-row], [data-composer-header], header, div.h-14, div.min-h-14, div.h-12, div.h-16, div.min-h-16';