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",
|
backgroundColor: "#fff",
|
||||||
|
|
||||||
titleBarStyle: process.platform === "darwin" ? "hidden" : "default",
|
titleBarStyle: process.platform === "darwin" ? "hidden" : "default",
|
||||||
trafficLightPosition: { x: 9, y: 8 },
|
trafficLightPosition: { x: 12, y: 8 },
|
||||||
|
|
||||||
icon: await getIconPath(),
|
icon: await getIconPath(),
|
||||||
show: false,
|
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 { afterEach, describe, expect, it, vi, type Mock } from "vitest";
|
||||||
import type { BrowserWindow } from "electron";
|
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.
|
* Extract the declaration block for the first rule whose selector list contains 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 {
|
function ruleBlock(css: string, selector: string): string {
|
||||||
const escaped = selector.replace(/[.*+?^${}()|[\]\\]/g, "\\$&");
|
const escaped = selector.replace(/[.*+?^${}()|[\]\\]/g, "\\$&");
|
||||||
const blockRegex = new RegExp(`([^{}]*${escaped}[^{}]*)\\{([^}]*)\\}`, "g");
|
const blockMatch = new RegExp(`[^{}]*${escaped}[^{}]*\\{([^}]*)\\}`).exec(css);
|
||||||
let match: RegExpExecArray | null;
|
expect(blockMatch, `expected a rule block for "${selector}"`).not.toBeNull();
|
||||||
let foundBlock = false;
|
return blockMatch![1];
|
||||||
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", () => {
|
describe("buildTitleBarCss", () => {
|
||||||
@@ -42,56 +29,76 @@ describe("buildTitleBarCss", () => {
|
|||||||
expect(css.length).toBeGreaterThan(0);
|
expect(css.length).toBeGreaterThan(0);
|
||||||
});
|
});
|
||||||
|
|
||||||
it.each([".mx_RoomView::before", ".mx_LeftPanel::before", ".mx_SpaceRoomView::before"])(
|
it("draws the title bar band at the designed height", () => {
|
||||||
"marks %s as a drag handle",
|
const bar = ruleBlock(css, "body::before");
|
||||||
(selector) => {
|
expect(bar).toMatch(new RegExp(`height:\\s*${TITLE_BAR_HEIGHT_PX}px`));
|
||||||
const escaped = selector.replace(/[.*+?^${}()|[\]\\]/g, "\\$&");
|
expect(bar).toMatch(/position:\s*fixed/);
|
||||||
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)", () => {
|
it("styles the title bar with the canvas background and separator tokens", () => {
|
||||||
expect(dragStripHeightPx(css, ".mx_LeftPanel::before")).toBeGreaterThanOrEqual(28);
|
// 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)", () => {
|
it("makes the title bar a drag handle", () => {
|
||||||
expect(dragStripHeightPx(css, ".mx_SpaceRoomView::before")).toBeGreaterThanOrEqual(28);
|
expect(ruleBlock(css, "body::before")).toMatch(/-webkit-app-region:\s*drag/);
|
||||||
});
|
});
|
||||||
|
|
||||||
it("keeps the left panel's separator on its drag strip", () => {
|
it("pushes the app content below the title bar band", () => {
|
||||||
// The strip carries the panel's right-hand border up through the title bar band; widening it must not drop it.
|
const body = ruleBlock(css, "body");
|
||||||
expect(css).toMatch(
|
expect(body).toMatch(new RegExp(`padding-top:\\s*${TITLE_BAR_HEIGHT_PX}px`));
|
||||||
/\.mx_LeftPanel::before\s*\{[^}]*border-right:\s*1px\s+solid\s+var\(--cpd-color-bg-subtle-primary\)/,
|
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
|
it("keeps the lightbox header a drag handle with interactive elements excluded", () => {
|
||||||
// separator crowds the green traffic light, so the panel is widened to clear it.
|
expect(ruleBlock(css, ".mx_ImageView_panel")).toMatch(/-webkit-app-region:\s*drag/);
|
||||||
it("widens the collapsed space panel so the separator clears the traffic lights", () => {
|
expect(css).toMatch(/\.mx_ImageView_panel\s*>\s*\.mx_ImageView_toolbar\s*>\s*\*\s*\{[^}]*no-drag/);
|
||||||
expect(css).toMatch(/\.mx_SpacePanel\.collapsed\s*\{[^}]*width:\s*76px\s*!important/);
|
|
||||||
});
|
});
|
||||||
|
|
||||||
it("keeps interactive elements excluded from the drag region (no-drag)", () => {
|
it("keeps context menus excluded from the drag region (no-drag)", () => {
|
||||||
// The UserMenu buttons must remain clickable, not act as a drag handle.
|
expect(ruleBlock(css, ".mx_ContextualMenu")).toMatch(/-webkit-app-region:\s*no-drag/);
|
||||||
expect(css).toMatch(/\.mx_UserMenu\s*>\s*\*\s*\{[^}]*-webkit-app-region:\s*no-drag/);
|
|
||||||
});
|
});
|
||||||
|
|
||||||
it("keeps iframes excluded from the drag region (no-drag)", () => {
|
it("keeps iframes excluded from the drag region (no-drag)", () => {
|
||||||
// iframes (e.g. recaptcha, widgets) must remain interactive.
|
// iframes (e.g. recaptcha, widgets) must remain interactive.
|
||||||
expect(css).toMatch(/iframe\s*\{[^}]*-webkit-app-region:\s*no-drag/);
|
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", () => {
|
describe("setupMacosTitleBar", () => {
|
||||||
|
|||||||
@@ -8,51 +8,61 @@ Please see LICENSE files in the repository root for full details.
|
|||||||
import type { BrowserWindow } from "electron";
|
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
|
* `electron-main.ts` uses `titleBarStyle: "hidden"`, which keeps the native window frame, rounded
|
||||||
* to drag the window is via `-webkit-app-region: drag` strips. The `::before` strips above the room and
|
* corners and traffic lights but removes the native bar surface. This CSS paints that surface: a
|
||||||
* left-panel headers were previously ~13px tall and too small to reliably grab (#32018); they are raised
|
* full-width strip at the top of the window in the canvas background colour with a hairline
|
||||||
* to match the 32px traffic-light offset used elsewhere in this file. Interactive controls keep
|
* separator underneath, and pushes the app content below it. The strip is the window's drag
|
||||||
* `-webkit-app-region: no-drag` so they remain clickable (an element must never be both clickable and a
|
* handle.
|
||||||
* 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).
|
* Extracted as a pure helper so the string contract can be unit-tested (see macos-titlebar.test.ts).
|
||||||
*/
|
*/
|
||||||
export function buildTitleBarCss(): string {
|
export function buildTitleBarCss(): string {
|
||||||
return `
|
return `
|
||||||
/* Create margin of space for the traffic light buttons */
|
/* Reserve a band at the top of the window for the title bar */
|
||||||
.mx_UserMenu {
|
body {
|
||||||
/* We zero the margin and use padding as we want to use it as a drag handle */
|
box-sizing: border-box;
|
||||||
margin-top: 0 !important;
|
height: 100%;
|
||||||
margin-left: 0 !important;
|
padding-top: ${TITLE_BAR_HEIGHT_PX}px !important;
|
||||||
padding-top: 32px !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-app-region: drag;
|
||||||
-webkit-user-select: none;
|
-webkit-user-select: none;
|
||||||
}
|
}
|
||||||
/* Exclude the button from being a drag handle and not working */
|
|
||||||
.mx_UserMenu > * {
|
/* Exclude floating menus and tooltips, which render in body-level portals */
|
||||||
-webkit-app-region: no-drag;
|
[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 */
|
/* Mark the splash screen as a drag handle */
|
||||||
.mx_MatrixChat_splash {
|
.mx_MatrixChat_splash {
|
||||||
-webkit-app-region: drag;
|
-webkit-app-region: drag;
|
||||||
@@ -61,7 +71,7 @@ export function buildTitleBarCss(): string {
|
|||||||
.mx_MatrixChat_splashButtons {
|
.mx_MatrixChat_splashButtons {
|
||||||
-webkit-app-region: no-drag;
|
-webkit-app-region: no-drag;
|
||||||
}
|
}
|
||||||
|
|
||||||
/* Mark the background as a drag handle */
|
/* Mark the background as a drag handle */
|
||||||
.mx_AuthPage {
|
.mx_AuthPage {
|
||||||
-webkit-app-region: drag;
|
-webkit-app-region: drag;
|
||||||
@@ -73,18 +83,12 @@ export function buildTitleBarCss(): string {
|
|||||||
.mx_AuthPage .mx_Dropdown_menu {
|
.mx_AuthPage .mx_Dropdown_menu {
|
||||||
-webkit-app-region: no-drag;
|
-webkit-app-region: no-drag;
|
||||||
}
|
}
|
||||||
|
|
||||||
/* Mark the home page background as a drag handle */
|
/* The image lightbox covers the whole window, including the title bar band; keep its
|
||||||
.mx_HomePage {
|
sender info clear of the traffic lights, and let its header double as a drag handle */
|
||||||
-webkit-app-region: drag;
|
.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 {
|
.mx_ImageView_panel {
|
||||||
-webkit-app-region: drag;
|
-webkit-app-region: drag;
|
||||||
}
|
}
|
||||||
@@ -94,27 +98,8 @@ export function buildTitleBarCss(): string {
|
|||||||
.mx_ImageView_panel > .mx_ImageView_toolbar > * {
|
.mx_ImageView_panel > .mx_ImageView_toolbar > * {
|
||||||
-webkit-app-region: no-drag;
|
-webkit-app-region: no-drag;
|
||||||
}
|
}
|
||||||
|
|
||||||
/* Mark the background as a drag handle only if no modal is open */
|
/* Exclude context menus and their backgrounds, which may open within the band */
|
||||||
.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 */
|
|
||||||
.mx_ContextualMenu, .mx_ContextualMenu_background {
|
.mx_ContextualMenu, .mx_ContextualMenu_background {
|
||||||
-webkit-app-region: no-drag;
|
-webkit-app-region: no-drag;
|
||||||
}
|
}
|
||||||
@@ -122,37 +107,6 @@ export function buildTitleBarCss(): string {
|
|||||||
iframe {
|
iframe {
|
||||||
-webkit-app-region: no-drag;
|
-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