Draw a styled 32px macOS title bar band (#34419)
Draw a styled 32px macOS title bar Replace the per-surface drag-strip hacks with a single full-width title bar band matching the design: canvas background, hairline separator, and draggable. Recentre the traffic lights for the taller band. Dialogs and floating overlays stay clickable when they reach into the band while the rest of the bar remains draggable.
This commit is contained in:
@@ -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,
|
||||
|
||||
@@ -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", () => {
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
`;
|
||||
}
|
||||
|
||||
|
||||
Reference in New Issue
Block a user