feat(ui): add custom title bar window controls for Windows and Linux

Hide the native title bar on win32/linux (titleBarStyle: 'hidden', frame
untouched so native resize borders and snapping keep working) and render
minimize / maximize-restore / close buttons in the renderer, mirroring the
existing macOS traffic-light setup.

- New WINDOW:* IPC contract (minimize, toggle-maximize, close, get-state)
  handled in window.events.ts, resolved from the sender WebContents;
  close goes through win.close() so window-bounds persistence still runs.
- WINDOW:STATE_CHANGED pushed on maximize/unmaximize/fullscreen so the
  maximize/restore glyph stays correct for OS-triggered changes; controls
  hide while fullscreen.
- WindowControlsComponent mounts once in app-root as a manual popover so
  it stays in the browser top layer above CDK overlays (dialogs,
  multi-EPG) - same behavior as macOS traffic lights.
- Theme-aware via CSS vars (--app-on-surface, --app-hover-overlay);
  Windows-red close hover. Drag regions get right padding through a
  body-level frameless-platform class.
- Gated by RuntimeCapabilitiesService.usesCustomWindowControls; PWA and
  macOS never mount the controls.

Includes unit specs for the component and IPC handlers, an Electron E2E
suite (window-controls.e2e.ts), and a window-chrome section in
docs/architecture/workspace-shell.md.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
This commit is contained in:
4grayandClaude Fable 5 committed 2026-06-10 20:46:21 +02:00
1 parent 2be42257ab
commit 24c2da48ae
18 files changed
+939 -25

No files matched your search

@@ -0,0 +1,109 @@
import {
closeElectronApp,
expect,
launchElectronApp,
test,
} from './electron-test-fixtures';
// Custom window controls are only rendered on Windows/Linux; macOS keeps
// the native traffic lights.
test.describe('Custom window controls', () => {
test.skip(
process.platform === 'darwin',
'macOS uses native traffic lights instead of custom controls'
);
test('@electron renders the window control buttons', async ({
dataDir,
}) => {
const app = await launchElectronApp(dataDir);
try {
await expect(
app.mainWindow.getByTestId('window-minimize')
).toBeVisible();
await expect(
app.mainWindow.getByTestId('window-maximize')
).toBeVisible();
await expect(
app.mainWindow.getByTestId('window-close')
).toBeVisible();
await expect(
app.mainWindow.getByTestId('window-maximize-glyph')
).toBeVisible();
} finally {
await closeElectronApp(app);
}
});
test('@electron maximize button toggles the native window state', async ({
dataDir,
}) => {
const app = await launchElectronApp(dataDir);
try {
const isMaximized = () =>
app.electronApp.evaluate(({ BrowserWindow }) => {
const mainWindow = BrowserWindow.getAllWindows()[0];
return mainWindow ? mainWindow.isMaximized() : false;
});
await app.mainWindow.getByTestId('window-maximize').click();
await expect.poll(isMaximized, { timeout: 10_000 }).toBe(true);
await expect(
app.mainWindow.getByTestId('window-restore-glyph')
).toBeVisible();
await app.mainWindow.getByTestId('window-maximize').click();
await expect.poll(isMaximized, { timeout: 10_000 }).toBe(false);
await expect(
app.mainWindow.getByTestId('window-maximize-glyph')
).toBeVisible();
} finally {
await closeElectronApp(app);
}
});
test('@electron reflects window state changes triggered from the main process', async ({
dataDir,
}) => {
const app = await launchElectronApp(dataDir);
try {
await app.electronApp.evaluate(({ BrowserWindow }) => {
BrowserWindow.getAllWindows()[0]?.maximize();
});
await expect(
app.mainWindow.getByTestId('window-restore-glyph')
).toBeVisible({ timeout: 10_000 });
await app.electronApp.evaluate(({ BrowserWindow }) => {
BrowserWindow.getAllWindows()[0]?.unmaximize();
});
await expect(
app.mainWindow.getByTestId('window-maximize-glyph')
).toBeVisible({ timeout: 10_000 });
} finally {
await closeElectronApp(app);
}
});
test('@electron minimize button minimizes the window', async ({
dataDir,
}) => {
const app = await launchElectronApp(dataDir);
try {
const isMinimized = () =>
app.electronApp.evaluate(({ BrowserWindow }) => {
const mainWindow = BrowserWindow.getAllWindows()[0];
return mainWindow ? mainWindow.isMinimized() : false;
});
await app.mainWindow.getByTestId('window-minimize').click();
await expect.poll(isMinimized, { timeout: 10_000 }).toBe(true);
} finally {
await closeElectronApp(app);
}
});
});