diff --git a/CHANGELOG.md b/CHANGELOG.md index e86b5b79..40eb929e 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -4,6 +4,10 @@ User-facing changes to Beam are documented in this file. ## [Unreleased] +### Fixed + +- HUD and recording controls start with an explicitly transparent, fixed native surface on Windows, without the system resize frame. Ordinary HUD/Recorder switches no longer reset the native backdrop; preparation still restores its material when it closes. + ### Changed - Refreshed the repository README with current Beam workflows, website imagery, platform downloads, CLI guidance, and the community's non-expiring Discord invite. diff --git a/apps/desktop/electron/lifecycle/renderer-setup.cjs b/apps/desktop/electron/lifecycle/renderer-setup.cjs index 9a690a5e..031efa1b 100644 --- a/apps/desktop/electron/lifecycle/renderer-setup.cjs +++ b/apps/desktop/electron/lifecycle/renderer-setup.cjs @@ -109,10 +109,14 @@ function createRendererSetup({ height: initialSize.height, frame: false, transparent: true, + backgroundColor: '#00000000', + // Transparent overlays must be non-resizable from their first native frame. + // Changing that after ready-to-show can leave an opaque Windows surround. + thickFrame: false, alwaysOnTop: true, icon: appIconPath, - resizable: true, - maximizable: true, + resizable: false, + maximizable: false, hasShadow: false, visualEffectState: 'active', show: false, diff --git a/apps/desktop/electron/window/window-controller.cjs b/apps/desktop/electron/window/window-controller.cjs index e3c4ba51..ebdc6242 100644 --- a/apps/desktop/electron/window/window-controller.cjs +++ b/apps/desktop/electron/window/window-controller.cjs @@ -199,6 +199,7 @@ class WindowController { setMode(mode, { restoreMaximized = true } = {}) { if (!['hud', 'recorder', 'preparation'].includes(mode)) throw new Error(`Mode de fenêtre invalide: ${mode}`); + const changesPreparationMaterial = (this.mode === 'preparation') !== (mode === 'preparation'); if (this.mode === 'recorder' && mode !== 'recorder') { this.rememberRecorderPosition(); this.flushRecorderPosition(); @@ -209,7 +210,8 @@ class WindowController { } if (this.mode === 'preparation' && mode !== 'preparation') this.window.emit('capture-preparation-ended'); this.mode = mode; - applyPreparationMaterial(this.window, this.platform, mode === 'preparation'); + // Do not reset the Windows native backdrop during ordinary HUD/Recorder switches. + if (changesPreparationMaterial) applyPreparationMaterial(this.window, this.platform, mode === 'preparation'); this.hudDragging = false; if (mode === 'hud') this.hudSize = this.readHudSize(); this.applySizeConstraints(); diff --git a/docs/electron_window.md b/docs/electron_window.md index 5eba358b..69de9d0a 100644 --- a/docs/electron_window.md +++ b/docs/electron_window.md @@ -14,6 +14,8 @@ The shared overlay entry selects camera, Quick Snip settings or Crop Bar through The transparent main window is controlled by `apps/desktop/electron/window/window-controller.cjs` and owns the HUD and Recorder modes. The editor uses a separate opaque window created by `apps/desktop/electron/window/editor-window.cjs`; this separation is required for native window animations and Windows Snap Layouts. +Create the shared HUD/Recorder window with an explicitly transparent native background, `resizable: false`, `maximizable: false` and `thickFrame: false` before its first paint. Changing a resizable transparent window only after `ready-to-show` can invalidate transparency on Windows. Native bounds changes still resize the fixed overlay for its current mode; opaque editor windows retain their native frame. Toggle preparation vibrancy/acrylic only when entering or leaving preparation, never during ordinary HUD/Recorder switches or repeated mode reports. + | Mode | Bounds / behavior | Interaction | | ---------- | --------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | | `hud` | Compact at `672 × 268` (temporarily taller while a popover is open): a `640 × 236` HUD card plus 16 px of room on every edge for its border and shadow. The canonical dimensions live in `preferences.json` as `hudWindow`; startup normalization restores them when missing or unexpected. It returns to its prior HUD position after Recorder mode. Dragging is bounded by the physical display bounds, not the taskbar-excluded work area. | macOS/Windows: transparent pixels pass through; renderer enables mouse handling only over interactive HUD elements. Linux: forced X11/XWayland, fully interactive compact native bounds; HUD popovers scroll within them. | diff --git a/test/renderer-setup-window.test.cjs b/test/renderer-setup-window.test.cjs index bd8788c6..81ac1e15 100644 --- a/test/renderer-setup-window.test.cjs +++ b/test/renderer-setup-window.test.cjs @@ -171,6 +171,38 @@ test('loads the HUD from its session port and trusts only that renderer origin', assert.equal(setup.isTrustedRenderer('http://localhost:6508/html/untrusted.html'), false); }); +test('creates the shared HUD/Recorder with a transparent native backing before its first paint', () => { + const fixture = createFixture(); + assert.equal(fixture.window.options.transparent, true); + assert.equal(fixture.window.options.backgroundColor, '#00000000'); + assert.equal( + fixture.calls.some(([name]) => name === 'showInactive'), + false, + ); + assert.equal( + fixture.calls.some(([name]) => name === 'setResizable'), + false, + ); +}); + +test('starts transparent overlays non-resizable instead of correcting them after ready-to-show', () => { + const fixture = createFixture(); + assert.equal(fixture.window.options.resizable, false); + assert.equal(fixture.window.options.maximizable, false); + fixture.window.emit('ready-to-show'); + assert.ok(fixture.calls.filter(([name]) => name === 'setResizable').every((call) => call[1] === false)); + assert.ok(fixture.calls.filter(([name]) => name === 'setMaximizable').every((call) => call[1] === false)); +}); + +test('excludes the Windows native frame and shadow from the transparent recorder surface', () => { + const fixture = createFixture(); + assert.equal(fixture.window.options.frame, false); + assert.equal(fixture.window.options.thickFrame, false); + assert.equal(fixture.window.options.hasShadow, false); + assert.equal(fixture.window.options.width, 672); + assert.equal(fixture.window.options.height, 268); +}); + test('packaged renderers cannot obtain media permissions from a development origin', () => { const setup = createRendererSetup({ app: { isPackaged: true }, diff --git a/test/window-controller.test.cjs b/test/window-controller.test.cjs index 4e0499ca..a9b308ab 100644 --- a/test/window-controller.test.cjs +++ b/test/window-controller.test.cjs @@ -799,6 +799,61 @@ test('failed HUD wake remains hidden and reports the failure', async (t) => { assert.equal(errors[0][0], '[HUD wake]'); }); +for (const platform of ['linux', 'darwin', 'win32']) + test(`ordinary HUD/Recorder mode changes leave native backdrop state alone on ${platform}`, () => { + const win = fakeWindow(); + win.setVibrancy = (value) => win.calls.push(['vibrancy', value]); + win.setBackgroundMaterial = (value) => win.calls.push(['material', value]); + const controller = new WindowController(win, { + platform, + screenModule: { + getCursorScreenPoint: () => ({ x: 960, y: 540 }), + getDisplayNearestPoint: () => ({ id: 1, workArea: { x: 0, y: 0, width: 1920, height: 1040 } }), + }, + }); + controller.setMode('hud'); + controller.setMode('recorder'); + controller.setMode('recorder'); + controller.setMode('hud'); + assert.deepEqual( + win.calls.filter(([name]) => ['vibrancy', 'material'].includes(name)), + [], + ); + assert.ok(win.calls.some(([name, bounds]) => name === 'bounds' && bounds.width === 352 && bounds.height === 88)); + assert.ok(win.calls.filter(([name]) => name === 'resizable').every((call) => call[1] === false)); + }); + +for (const platform of ['darwin', 'win32']) + test(`preparation applies and removes its native backdrop exactly once on ${platform}`, () => { + const win = fakeWindow(); + win.setVibrancy = (value) => win.calls.push(['vibrancy', value]); + win.setBackgroundMaterial = (value) => win.calls.push(['material', value]); + const controller = new WindowController(win, { + platform, + screenModule: { + getCursorScreenPoint: () => ({ x: 960, y: 540 }), + getDisplayNearestPoint: () => ({ id: 1, workArea: { x: 0, y: 0, width: 1920, height: 1040 } }), + }, + }); + controller.setMode('preparation'); + controller.setMode('preparation'); + controller.setMode('recorder'); + controller.setMode('recorder'); + controller.setMode('hud'); + assert.deepEqual( + win.calls.filter(([name]) => ['vibrancy', 'material'].includes(name)), + platform === 'win32' + ? [ + ['material', 'acrylic'], + ['material', 'none'], + ] + : [ + ['vibrancy', 'under-window'], + ['vibrancy', null], + ], + ); + }); + for (const platform of ['linux', 'darwin', 'win32']) test(`preparation stays compact, protected and topmost on ${platform} and restores native material`, () => { const win = fakeWindow();