diff --git a/apps/desktop/src/electron-main.ts b/apps/desktop/src/electron-main.ts index 74c620d357..3af1da6f57 100644 --- a/apps/desktop/src/electron-main.ts +++ b/apps/desktop/src/electron-main.ts @@ -255,7 +255,7 @@ app.on("ready", async () => { backgroundColor: "#fff", titleBarStyle: process.platform === "darwin" ? "hidden" : "default", - trafficLightPosition: { x: 9, y: 8 }, + trafficLightPosition: { x: 12, y: 8 }, icon: await getIconPath(), show: false, diff --git a/apps/desktop/src/macos-titlebar.test.ts b/apps/desktop/src/macos-titlebar.test.ts index 0d2862962d..5a9a5f2ab9 100644 --- a/apps/desktop/src/macos-titlebar.test.ts +++ b/apps/desktop/src/macos-titlebar.test.ts @@ -9,29 +9,16 @@ Please see LICENSE files in the repository root for full details. import { afterEach, describe, expect, it, vi, type Mock } from "vitest"; import type { BrowserWindow } from "electron"; -import { buildTitleBarCss, setupMacosTitleBar } from "./macos-titlebar.js"; +import { buildTitleBarCss, setupMacosTitleBar, TITLE_BAR_HEIGHT_PX } from "./macos-titlebar.js"; /** - * 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. + * Extract the declaration block for the first rule whose selector list contains the given selector. */ -function dragStripHeightPx(css: string, selector: string): number { +function ruleBlock(css: string, selector: string): string { 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}"`); + const blockMatch = new RegExp(`[^{}]*${escaped}[^{}]*\\{([^}]*)\\}`).exec(css); + expect(blockMatch, `expected a rule block for "${selector}"`).not.toBeNull(); + return blockMatch![1]; } describe("buildTitleBarCss", () => { @@ -42,56 +29,76 @@ describe("buildTitleBarCss", () => { 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("draws the title bar band at the designed height", () => { + const bar = ruleBlock(css, "body::before"); + expect(bar).toMatch(new RegExp(`height:\\s*${TITLE_BAR_HEIGHT_PX}px`)); + expect(bar).toMatch(/position:\s*fixed/); }); - it("gives .mx_LeftPanel::before a drag strip at least 28px tall (regression #32018)", () => { - expect(dragStripHeightPx(css, ".mx_LeftPanel::before")).toBeGreaterThanOrEqual(28); + it("styles the title bar with the canvas background and separator tokens", () => { + // Matches the design spec: bg/canvas/default fill with a 1px separator/primary hairline below. + const bar = ruleBlock(css, "body::before"); + expect(bar).toMatch(/background:\s*var\(--cpd-color-bg-canvas-default\b/); + expect(bar).toMatch(/border-bottom:\s*1px\s+solid\s+var\(--cpd-color-separator-primary\b/); }); - it("gives .mx_SpaceRoomView::before a drag strip at least 28px tall (regression #32018)", () => { - expect(dragStripHeightPx(css, ".mx_SpaceRoomView::before")).toBeGreaterThanOrEqual(28); + it("makes the title bar a drag handle", () => { + expect(ruleBlock(css, "body::before")).toMatch(/-webkit-app-region:\s*drag/); }); - 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\)/, + it("pushes the app content below the title bar band", () => { + const body = ruleBlock(css, "body"); + expect(body).toMatch(new RegExp(`padding-top:\\s*${TITLE_BAR_HEIGHT_PX}px`)); + expect(body).toMatch(/box-sizing:\s*border-box/); + }); + + it("keeps the window draggable through an overlapping dialog panel", () => { + // Regression guard: a blanket `.mx_Dialog`/`.mx_Dialog_border` no-drag carves the panel + // (incl. the Glass border) out of the band, killing the drag where a centred dialog overlaps + // it. The panel must stay transparent to the drag calc so body::before shows through. + expect(css).not.toMatch(/(^|[^_-])\.mx_Dialog\s*\{[^}]*no-drag/); + expect(css).not.toMatch(/\.mx_Dialog_border\b[^{]*\{[^}]*no-drag/); + }); + + it("does not gate the drag region on a modal being open", () => { + // Regression guard: an earlier draft no-dragged the whole bar via the aria-hidden modal + // signal, making the window undraggable with e.g. the settings dialog open. + expect(css).not.toContain("aria-hidden"); + }); + + it("keeps floating portal overlays clickable within the band", () => { + // Compound menus/tooltips render in body-level portals and can open anywhere, incl. the band. + expect(css).toMatch(/\[data-radix-popper-content-wrapper\][^{]*\{[^}]*-webkit-app-region:\s*no-drag/); + }); + + it("no longer carves per-surface drag strips into the app chrome", () => { + // The dedicated bar replaces the old hacks; their reappearance would double up the offset. + expect(css).not.toContain(".mx_LeftPanel::before"); + expect(css).not.toContain(".mx_RoomView::before"); + expect(css).not.toContain(".mx_SpaceRoomView::before"); + expect(css).not.toContain(".mx_UserMenu"); + expect(css).not.toContain(".mx_SpacePanel"); + }); + + it("keeps the lightbox sender info clear of the traffic lights", () => { + expect(ruleBlock(css, ".mx_ImageView_info_wrapper")).toMatch( + new RegExp(`margin-top:\\s*${TITLE_BAR_HEIGHT_PX}px`), ); }); - // 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 the lightbox header a drag handle with interactive elements excluded", () => { + expect(ruleBlock(css, ".mx_ImageView_panel")).toMatch(/-webkit-app-region:\s*drag/); + expect(css).toMatch(/\.mx_ImageView_panel\s*>\s*\.mx_ImageView_toolbar\s*>\s*\*\s*\{[^}]*no-drag/); }); - 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 context menus excluded from the drag region (no-drag)", () => { + expect(ruleBlock(css, ".mx_ContextualMenu")).toMatch(/-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", () => { diff --git a/apps/desktop/src/macos-titlebar.ts b/apps/desktop/src/macos-titlebar.ts index 11dae63fe7..22c18830bd 100644 --- a/apps/desktop/src/macos-titlebar.ts +++ b/apps/desktop/src/macos-titlebar.ts @@ -8,51 +8,61 @@ Please see LICENSE files in the repository root for full details. import type { BrowserWindow } from "electron"; /** - * Build the CSS injected into the renderer to make the (native-title-bar-less) macOS window draggable. + * Height of the styled title bar band, matching the design spec + * (https://www.figma.com/design/MAUsalKv7bRRNKlAAigtNd/Community-contributions?node-id=76-12996). + * The `trafficLightPosition` in `electron-main.ts` vertically centres the native window controls + * within this band — keep the two in sync. + */ +export const TITLE_BAR_HEIGHT_PX = 32; + +/** + * Build the CSS injected into the renderer to draw the macOS title bar band. * - * 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). + * `electron-main.ts` uses `titleBarStyle: "hidden"`, which keeps the native window frame, rounded + * corners and traffic lights but removes the native bar surface. This CSS paints that surface: a + * full-width strip at the top of the window in the canvas background colour with a hairline + * separator underneath, and pushes the app content below it. The strip is the window's drag + * handle. + * + * The drag region is built from `-webkit-app-region` rects only: elements keep the default value + * (`none`) and are ignored unless they explicitly set `drag`/`no-drag`. So the band stays draggable + * underneath any overlay whose surface is transparent to the calc (a dialog's backdrop, a menu's + * container), and an overlay panel that a user clicks (dialog panels, context menus, the lightbox + * chrome) sets `no-drag` so its rect is subtracted and it stays interactive. 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 */ - margin-top: 0 !important; - margin-left: 0 !important; - padding-top: 32px !important; + /* Reserve a band at the top of the window for the title bar */ + body { + box-sizing: border-box; + height: 100%; + padding-top: ${TITLE_BAR_HEIGHT_PX}px !important; + } + + /* The title bar itself: canvas background with a hairline separator below */ + body::before { + content: ""; + position: fixed; + top: 0; + left: 0; + right: 0; + height: ${TITLE_BAR_HEIGHT_PX}px; + box-sizing: border-box; + /* Fallback colours for pages loaded without the app themes (e.g. the error view) */ + background: var(--cpd-color-bg-canvas-default, #ffffff); + border-bottom: 1px solid var(--cpd-color-separator-primary, #e1e6ec); -webkit-app-region: drag; -webkit-user-select: none; } - /* Exclude the button from being a drag handle and not working */ - .mx_UserMenu > * { - -webkit-app-region: no-drag; + + /* Exclude floating menus and tooltips, which render in body-level portals */ + [data-radix-popper-content-wrapper] { + -webkit-app-region: no-drag; } - /* Maintain alignment of the toggle space panel button */ - .mx_SpacePanel_toggleCollapse { - /* 19px original top value, 32px margin-top above, 12px original margin-top value */ - top: calc(19px + 32px - 12px) !important; - } - /* Widen the collapsed space panel so its right-hand separator clears the - traffic light buttons. The buttons are inset 9px (see trafficLightPosition - in electron-main) and the three-button cluster is ~52px wide, ending ~61px - from the window edge; against the default 68px rail the separator crowds the - green button. 76px restores ~15px of clearance, matching the compound 4x - spacing step. */ - .mx_SpacePanel.collapsed { - width: 76px !important; - } - /* Prevent the media lightbox sender info from clipping into the traffic light buttons */ - .mx_ImageView_info_wrapper { - margin-top: 32px; - } - + /* Mark the splash screen as a drag handle */ .mx_MatrixChat_splash { -webkit-app-region: drag; @@ -61,7 +71,7 @@ export function buildTitleBarCss(): string { .mx_MatrixChat_splashButtons { -webkit-app-region: no-drag; } - + /* Mark the background as a drag handle */ .mx_AuthPage { -webkit-app-region: drag; @@ -73,18 +83,12 @@ export function buildTitleBarCss(): string { .mx_AuthPage .mx_Dropdown_menu { -webkit-app-region: no-drag; } - - /* Mark the home page background as a drag handle */ - .mx_HomePage { - -webkit-app-region: drag; + + /* The image lightbox covers the whole window, including the title bar band; keep its + sender info clear of the traffic lights, and let its header double as a drag handle */ + .mx_ImageView_info_wrapper { + margin-top: ${TITLE_BAR_HEIGHT_PX}px; } - /* Exclude interactive elements from being drag handles */ - .mx_HomePage .mx_HomePage_body, - .mx_HomePage .mx_HomePage_default_wrapper > * { - -webkit-app-region: no-drag; - } - - /* Mark the header as a drag handle */ .mx_ImageView_panel { -webkit-app-region: drag; } @@ -94,27 +98,8 @@ export function buildTitleBarCss(): string { .mx_ImageView_panel > .mx_ImageView_toolbar > * { -webkit-app-region: no-drag; } - - /* Mark the background as a drag handle only if no modal is open */ - .mx_MatrixChat_wrapper[aria-hidden="false"] .mx_RoomView_wrapper, - .mx_MatrixChat_wrapper[aria-hidden="false"] .mx_HomePage { - -webkit-app-region: drag; - } - /* Exclude content elements from being drag handles */ - .mx_SpaceRoomView_landing > *, - .mx_RoomPreviewBar, - .mx_RoomView_body, - .mx_AutoHideScrollbar, - .mx_RightPanel_ResizeWrapper, - .mx_RoomPreviewCard, - .mx_LeftPanel, - .mx_RoomView, - .mx_SpaceRoomView, - .mx_AccessibleButton, - .mx_Dialog { - -webkit-app-region: no-drag; - } - /* Exclude context menus and their backgrounds */ + + /* Exclude context menus and their backgrounds, which may open within the band */ .mx_ContextualMenu, .mx_ContextualMenu_background { -webkit-app-region: no-drag; } @@ -122,37 +107,6 @@ export function buildTitleBarCss(): string { iframe { -webkit-app-region: no-drag; } - - /* Add a bar above room header + left panel */ - - .mx_LeftPanel { - flex-direction: column; - } - - .mx_LeftPanel::before { - content: ""; - /* 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; - } - - .mx_RoomView::before, - .mx_SpaceRoomView::before { - content: ""; - -webkit-app-region: drag; - } - - .mx_SpaceRoomView::before { - display: block; - /* Enlarged to match the traffic-light offset for a comfortable drag zone (#32018) */ - height: 32px; - } - - .mx_RoomView::before { - /* Enlarged from 13px to cover the empty band above the 64px room header (#32018) */ - height: 32px; - } `; }