Widen the macOS title-bar drag strips so the window is easy to move (#33991)

* Widen the macOS title-bar drag strips so the window is easy to move

With titleBarStyle: "hidden" there is no native title bar, so the macOS window can only be
moved via the -webkit-app-region: drag strips above the room and left-panel headers. Those
strips were only ~13-24px tall and too thin to reliably grab, making the window awkward to
move.

Raise the drag strips above the room, left-panel and space headers to 32px (matching the
traffic-light offset already used elsewhere in the file) for a comfortable grab zone;
interactive controls keep -webkit-app-region: no-drag so they stay clickable. The injected CSS
is also extracted into a pure buildTitleBarCss() helper so the string contract can be
unit-tested.

* Address review: use author copyright, cover setupMacosTitleBar, drop dead selector

Use my own copyright on the new test file rather than New Vector's.

Add tests for setupMacosTitleBar() itself: the platform guard and the
enter-full-screen / leave-full-screen / did-finish-load lifecycle were
previously never executed, leaving the diff below the 80% coverage gate.

Drop the .mx_LeftPanel_newRoomList::before rule: #34040 removed that class
from the product, so the rule matched nothing and the border-right it carried
was silently lost from the left panel. Fold it back into .mx_LeftPanel::before,
with a regression test covering the separator.

---------

Co-authored-by: Michael Telatynski <7t3chguy@gmail.com>
This commit is contained in:
hayyaksi
2026-07-17 16:25:14 +00:00
committed by GitHub
co-authored by Michael Telatynski
parent 13aa6fcc46
commit dd7c9ed6fe
2 changed files with 231 additions and 60 deletions
+200 -48
View File
@@ -1,70 +1,222 @@
/*
Copyright 2026 Spencer Poisseroux
Copyright 2026 hayaksi1
SPDX-License-Identifier: AGPL-3.0-only OR GPL-3.0-only OR LicenseRef-Element-Commercial
Please see LICENSE files in the repository root for full details.
*/
import { expect, describe, it, beforeEach, afterEach, vi } from "vitest";
import { afterEach, describe, expect, it, vi, type Mock } from "vitest";
import type { BrowserWindow } from "electron";
import { setupMacosTitleBar } from "./macos-titlebar.js";
import { buildTitleBarCss, setupMacosTitleBar } from "./macos-titlebar.js";
function createFakeWindow(): {
window: BrowserWindow;
emitDidFinishLoad: () => void;
insertCSS: ReturnType<typeof vi.fn>;
} {
const listeners: Record<string, () => void> = {};
const insertCSS = vi.fn().mockResolvedValue("css-key");
const window = {
isFullScreen: vi.fn().mockReturnValue(false),
on: vi.fn(),
webContents: {
insertCSS,
removeInsertedCSS: vi.fn(),
on: vi.fn((event: string, listener: () => void) => {
listeners[event] = listener;
}),
},
} as unknown as BrowserWindow;
return {
window,
insertCSS,
emitDidFinishLoad: () => listeners["did-finish-load"]?.(),
};
/**
* Extract the `height` (in px) declared for the given selector.
*
* A selector may appear in more than one rule block (e.g. `.mx_SpaceRoomView::before` is both grouped with
* `.mx_RoomView::before` for the drag declaration and given its own block for the height). We scan every
* block whose selector list contains the target and return the height from the first block that declares one.
*/
function dragStripHeightPx(css: string, selector: string): number {
const escaped = selector.replace(/[.*+?^${}()|[\]\\]/g, "\\$&");
const blockRegex = new RegExp(`([^{}]*${escaped}[^{}]*)\\{([^}]*)\\}`, "g");
let match: RegExpExecArray | null;
let foundBlock = false;
while ((match = blockRegex.exec(css)) !== null) {
foundBlock = true;
const heightMatch = /height:\s*(\d+(?:\.\d+)?)px/.exec(match[2]);
if (heightMatch) {
return Number.parseFloat(heightMatch[1]);
}
}
expect(foundBlock, `expected a rule block for "${selector}"`).toBe(true);
throw new Error(`expected a px height declared for "${selector}"`);
}
describe("buildTitleBarCss", () => {
const css = buildTitleBarCss();
it("returns a non-empty CSS string", () => {
expect(typeof css).toBe("string");
expect(css.length).toBeGreaterThan(0);
});
it.each([".mx_RoomView::before", ".mx_LeftPanel::before", ".mx_SpaceRoomView::before"])(
"marks %s as a drag handle",
(selector) => {
const escaped = selector.replace(/[.*+?^${}()|[\]\\]/g, "\\$&");
const blockMatch = new RegExp(`${escaped}[^}]*\\{([^}]*)\\}`).exec(css);
expect(blockMatch, `expected a rule block for "${selector}"`).not.toBeNull();
expect(blockMatch![1]).toMatch(/-webkit-app-region:\s*drag/);
},
);
// Regression guard for #32018: the drag strips above the headers were ~13px and too small to grab.
it("gives .mx_RoomView::before a drag strip at least 28px tall (regression #32018)", () => {
expect(dragStripHeightPx(css, ".mx_RoomView::before")).toBeGreaterThanOrEqual(28);
});
it("gives .mx_LeftPanel::before a drag strip at least 28px tall (regression #32018)", () => {
expect(dragStripHeightPx(css, ".mx_LeftPanel::before")).toBeGreaterThanOrEqual(28);
});
it("gives .mx_SpaceRoomView::before a drag strip at least 28px tall (regression #32018)", () => {
expect(dragStripHeightPx(css, ".mx_SpaceRoomView::before")).toBeGreaterThanOrEqual(28);
});
it("keeps the left panel's separator on its drag strip", () => {
// The strip carries the panel's right-hand border up through the title bar band; widening it must not drop it.
expect(css).toMatch(
/\.mx_LeftPanel::before\s*\{[^}]*border-right:\s*1px\s+solid\s+var\(--cpd-color-bg-subtle-primary\)/,
);
});
// Regression guard for #34243: against the default 68px rail the collapsed space panel's right-hand
// separator crowds the green traffic light, so the panel is widened to clear it.
it("widens the collapsed space panel so the separator clears the traffic lights", () => {
expect(css).toMatch(/\.mx_SpacePanel\.collapsed\s*\{[^}]*width:\s*76px\s*!important/);
});
it("keeps interactive elements excluded from the drag region (no-drag)", () => {
// The UserMenu buttons must remain clickable, not act as a drag handle.
expect(css).toMatch(/\.mx_UserMenu\s*>\s*\*\s*\{[^}]*-webkit-app-region:\s*no-drag/);
});
it("keeps iframes excluded from the drag region (no-drag)", () => {
// iframes (e.g. recaptcha, widgets) must remain interactive.
expect(css).toMatch(/iframe\s*\{[^}]*-webkit-app-region:\s*no-drag/);
});
it("does not turn the traffic-light offset into a no-drag handle on .mx_UserMenu itself", () => {
// The UserMenu container itself stays a drag handle (only its children are no-drag).
expect(css).toMatch(/\.mx_UserMenu\s*\{[^}]*-webkit-app-region:\s*drag/);
});
});
describe("setupMacosTitleBar", () => {
/** Minimal `BrowserWindow` stand-in: the module only ever touches these members. */
function mockWindow(): {
window: BrowserWindow;
windowHandlers: Map<string, () => void>;
webContentsHandlers: Map<string, () => void>;
insertCSS: Mock;
removeInsertedCSS: Mock;
isFullScreen: Mock;
} {
const windowHandlers = new Map<string, () => void>();
const webContentsHandlers = new Map<string, () => void>();
const insertCSS = vi.fn<(css: string) => Promise<string>>().mockResolvedValue("css-key-1");
const removeInsertedCSS = vi.fn<(key: string) => Promise<void>>().mockResolvedValue(undefined);
const isFullScreen = vi.fn<() => boolean>().mockReturnValue(false);
const window = {
on: vi.fn((event: string, handler: () => void) => {
windowHandlers.set(event, handler);
}),
isFullScreen,
webContents: {
on: vi.fn((event: string, handler: () => void) => {
webContentsHandlers.set(event, handler);
}),
insertCSS,
removeInsertedCSS,
},
} as unknown as BrowserWindow;
return { window, windowHandlers, webContentsHandlers, insertCSS, removeInsertedCSS, isFullScreen };
}
/**
* The listeners are `() => void` and start `applyStyling()` without awaiting it, so awaiting a handler's
* own return value would prove nothing. Yield to the macrotask queue instead, which drains the pending
* microtasks and lets that fire-and-forget promise settle before we assert on its effects.
*/
function flushStyling(): Promise<void> {
return new Promise((resolve) => setTimeout(resolve, 0));
}
afterEach(() => {
vi.restoreAllMocks();
});
describe("on macOS", () => {
beforeEach(() => {
vi.spyOn(process, "platform", "get").mockReturnValue("darwin");
});
it("widens the collapsed space panel so the separator clears the traffic lights", async () => {
const { window, insertCSS, emitDidFinishLoad } = createFakeWindow();
setupMacosTitleBar(window);
emitDidFinishLoad();
// insertCSS is async; wait for the microtask queue to flush
await Promise.resolve();
expect(insertCSS).toHaveBeenCalledTimes(1);
const css = insertCSS.mock.calls[0][0] as string;
expect(css).toContain(".mx_SpacePanel.collapsed");
expect(css).toContain("width: 76px !important;");
});
});
it("does nothing on non-macOS platforms", () => {
it("does nothing on non-darwin platforms", () => {
vi.spyOn(process, "platform", "get").mockReturnValue("win32");
const { window, insertCSS, emitDidFinishLoad } = createFakeWindow();
const { window, insertCSS } = mockWindow();
setupMacosTitleBar(window);
emitDidFinishLoad();
expect(window.on).not.toHaveBeenCalled();
expect(window.webContents.on).not.toHaveBeenCalled();
expect(insertCSS).not.toHaveBeenCalled();
});
it("registers the full-screen and load listeners on darwin", () => {
vi.spyOn(process, "platform", "get").mockReturnValue("darwin");
const { window } = mockWindow();
setupMacosTitleBar(window);
expect(window.on).toHaveBeenCalledWith("enter-full-screen", expect.any(Function));
expect(window.on).toHaveBeenCalledWith("leave-full-screen", expect.any(Function));
expect(window.webContents.on).toHaveBeenCalledWith("did-finish-load", expect.any(Function));
});
it("injects the title bar CSS once the page has loaded", async () => {
vi.spyOn(process, "platform", "get").mockReturnValue("darwin");
const { window, webContentsHandlers, insertCSS } = mockWindow();
setupMacosTitleBar(window);
webContentsHandlers.get("did-finish-load")!();
await flushStyling();
expect(insertCSS).toHaveBeenCalledOnce();
expect(insertCSS).toHaveBeenCalledWith(buildTitleBarCss());
});
it("does not inject the CSS if the window loads while already full screen", async () => {
vi.spyOn(process, "platform", "get").mockReturnValue("darwin");
const { window, webContentsHandlers, insertCSS, isFullScreen } = mockWindow();
isFullScreen.mockReturnValue(true);
setupMacosTitleBar(window);
webContentsHandlers.get("did-finish-load")!();
await flushStyling();
expect(insertCSS).not.toHaveBeenCalled();
});
it("removes the injected CSS when entering full screen", async () => {
vi.spyOn(process, "platform", "get").mockReturnValue("darwin");
const { window, windowHandlers, webContentsHandlers, removeInsertedCSS } = mockWindow();
setupMacosTitleBar(window);
webContentsHandlers.get("did-finish-load")!();
await flushStyling();
windowHandlers.get("enter-full-screen")!();
expect(removeInsertedCSS).toHaveBeenCalledWith("css-key-1");
});
it("does not attempt to remove the CSS if none was ever injected", async () => {
vi.spyOn(process, "platform", "get").mockReturnValue("darwin");
const { window, windowHandlers, removeInsertedCSS } = mockWindow();
setupMacosTitleBar(window);
windowHandlers.get("enter-full-screen")!();
await flushStyling();
expect(removeInsertedCSS).not.toHaveBeenCalled();
});
it("re-injects the CSS when leaving full screen", async () => {
vi.spyOn(process, "platform", "get").mockReturnValue("darwin");
const { window, windowHandlers, insertCSS } = mockWindow();
setupMacosTitleBar(window);
windowHandlers.get("leave-full-screen")!();
await flushStyling();
expect(insertCSS).toHaveBeenCalledWith(buildTitleBarCss());
});
});
+31 -12
View File
@@ -7,13 +7,20 @@ Please see LICENSE files in the repository root for full details.
import type { BrowserWindow } from "electron";
export function setupMacosTitleBar(window: BrowserWindow): void {
if (process.platform !== "darwin") return;
let cssKey: string | undefined;
async function applyStyling(): Promise<void> {
cssKey = await window.webContents.insertCSS(`
/**
* Build the CSS injected into the renderer to make the (native-title-bar-less) macOS window draggable.
*
* Because `electron-main.ts` uses `titleBarStyle: "hidden"` there is no native title bar, so the only way
* to drag the window is via `-webkit-app-region: drag` strips. The `::before` strips above the room and
* left-panel headers were previously ~13px tall and too small to reliably grab (#32018); they are raised
* to match the 32px traffic-light offset used elsewhere in this file. Interactive controls keep
* `-webkit-app-region: no-drag` so they remain clickable (an element must never be both clickable and a
* drag handle).
*
* Extracted as a pure helper so the string contract can be unit-tested (see macos-titlebar.test.ts).
*/
export function buildTitleBarCss(): string {
return `
/* Create margin of space for the traffic light buttons */
.mx_UserMenu {
/* We zero the margin and use padding as we want to use it as a drag handle */
@@ -124,7 +131,8 @@ export function setupMacosTitleBar(window: BrowserWindow): void {
.mx_LeftPanel::before {
content: "";
height: 13px;
/* Aligned with the 32px traffic-light offset so the empty top band is grabbable (#32018) */
height: 32px;
border-right: 1px solid var(--cpd-color-bg-subtle-primary);
-webkit-app-region: drag;
}
@@ -134,16 +142,27 @@ export function setupMacosTitleBar(window: BrowserWindow): void {
content: "";
-webkit-app-region: drag;
}
.mx_SpaceRoomView::before {
display: block;
height: 24px;
/* Enlarged to match the traffic-light offset for a comfortable drag zone (#32018) */
height: 32px;
}
.mx_RoomView::before {
height: 13px;
/* Enlarged from 13px to cover the empty band above the 64px room header (#32018) */
height: 32px;
}
`);
`;
}
export function setupMacosTitleBar(window: BrowserWindow): void {
if (process.platform !== "darwin") return;
let cssKey: string | undefined;
async function applyStyling(): Promise<void> {
cssKey = await window.webContents.insertCSS(buildTitleBarCss());
}
window.on("enter-full-screen", () => {