diff --git a/src/main/window/createMainWindow.test.ts b/src/main/window/createMainWindow.test.ts index 0764fa3ad93..f612de57703 100644 --- a/src/main/window/createMainWindow.test.ts +++ b/src/main/window/createMainWindow.test.ts @@ -333,12 +333,43 @@ describe('createMainWindow', () => { } }) - it('never requests macOS vibrancy or transparency when window blur is enabled (#8482)', () => { - for (const [platform, expected] of [ - ['darwin', { backgroundMaterial: undefined }], - ['win32', { backgroundMaterial: 'acrylic' }], - ['linux', { backgroundMaterial: undefined }] - ] satisfies [NodeJS.Platform, { backgroundMaterial: string | undefined }][]) { + it('gives window blur a real material without transparency, and stays opaque when off (#8482, #8797)', () => { + for (const [platform, blur, expected] of [ + [ + 'darwin', + false, + { vibrancy: undefined, backgroundMaterial: undefined, background: '#ffffff' } + ], + [ + 'win32', + false, + { vibrancy: undefined, backgroundMaterial: undefined, background: '#ffffff' } + ], + [ + 'linux', + false, + { vibrancy: undefined, backgroundMaterial: undefined, background: '#ffffff' } + ], + [ + 'darwin', + true, + { vibrancy: 'under-window', backgroundMaterial: undefined, background: undefined } + ], + [ + 'win32', + true, + { vibrancy: undefined, backgroundMaterial: 'acrylic', background: undefined } + ], + ['linux', true, { vibrancy: undefined, backgroundMaterial: undefined, background: '#ffffff' }] + ] satisfies [ + NodeJS.Platform, + boolean, + { + vibrancy: string | undefined + backgroundMaterial: string | undefined + background: string | undefined + } + ][]) { browserWindowMock.mockReset() const webContents = { on: vi.fn(), @@ -373,16 +404,19 @@ describe('createMainWindow', () => { withPlatform(platform, () => createMainWindow({ getUI: () => ({}), - getSettings: () => ({ windowBackgroundBlur: true }), + getSettings: () => ({ windowBackgroundBlur: blur }), updateUI: vi.fn() } as never) ) const browserWindowOptions = browserWindowMock.mock.calls[0]?.[0] - expect(browserWindowOptions.vibrancy).toBeUndefined() + // Why: `transparent` is what suppressed vibrancy and forced per-frame alpha compositing (#8482). expect(browserWindowOptions.transparent).toBeUndefined() + expect(browserWindowOptions.vibrancy).toBe(expected.vibrancy) + expect(browserWindowOptions.visualEffectState).toBe(expected.vibrancy ? 'active' : undefined) expect(browserWindowOptions.backgroundMaterial).toBe(expected.backgroundMaterial) - expect(browserWindowOptions.backgroundColor).toBe('#ffffff') + // Why: an opaque fill would paint over the material, which is the #8797 no-op. + expect(browserWindowOptions.backgroundColor).toBe(expected.background) } }) diff --git a/src/main/window/createMainWindow.ts b/src/main/window/createMainWindow.ts index 3dc853e64a6..b7d919b4852 100644 --- a/src/main/window/createMainWindow.ts +++ b/src/main/window/createMainWindow.ts @@ -49,6 +49,7 @@ import { rectHasVisibleAreaOnAnyDisplay } from './window-bounds-validation' import { closeDashboardPopout } from './dashboard-popout-window' import { installPrivilegedWindowNavigationPolicy } from './privileged-window-navigation' import { isMacosTahoeOrNewer } from './macos-tahoe-release' +import { resolveMainWindowBlurSurface } from './main-window-blur-surface' import { registerPluginPanelNavigationGuard } from '../plugins/plugin-panel-navigation-guard' // Why: show/restore/resume can overlap before the size nudge resets; never capture the temporary width as the next baseline. @@ -247,11 +248,13 @@ export function createMainWindow( // Why: webview guests expose no safe transcript insertion target; let Cmd/Ctrl+E reach the page instead of dropping dictation text. return false }) - const blur = settings?.windowBackgroundBlur ?? false - // Why: only Windows acrylic is ever visible; macOS vibrancy+transparent sat behind our opaque background yet - // forced per-frame WindowServer alpha compositing (#8482). Applies at creation only, so it needs a restart. - const platformBlurOptions = - blur && process.platform === 'win32' ? { backgroundMaterial: 'acrylic' as const } : {} + // Why: the blur material is the window backdrop, so an opaque fill has to be dropped alongside it (#8797). + // Applies at creation only, so it needs a restart. + const { backgroundColor, blurOptions } = resolveMainWindowBlurSurface({ + platform: process.platform, + blur: settings?.windowBackgroundBlur ?? false, + dark: nativeTheme.shouldUseDarkColors + }) const mainWindow = new BrowserWindow({ width: savedBounds?.width ?? defaultBounds.width, @@ -265,7 +268,7 @@ export function createMainWindow( acceptFirstMouse: true, // Why: auto-hide the Windows/Linux menu bar to save a row (Alt reveals it); macOS uses the system menu bar anyway. autoHideMenuBar: true, - backgroundColor: nativeTheme.shouldUseDarkColors ? '#0a0a0a' : '#ffffff', + ...(backgroundColor ? { backgroundColor } : {}), // Why: macOS 'hiddenInset' keeps native traffic lights in our custom titlebar; Windows 'hidden' removes the OS title bar so it doesn't double up. titleBarStyle: process.platform === 'darwin' @@ -285,7 +288,7 @@ export function createMainWindow( } : {}), icon: getAppIconPath(settings?.appIcon), - ...platformBlurOptions, + ...blurOptions, webPreferences: { preload: join(__dirname, '../preload/index.js'), sandbox: true, diff --git a/src/main/window/main-window-blur-surface.test.ts b/src/main/window/main-window-blur-surface.test.ts new file mode 100644 index 00000000000..d714f9a08fc --- /dev/null +++ b/src/main/window/main-window-blur-surface.test.ts @@ -0,0 +1,50 @@ +import { describe, expect, it } from 'vitest' +import { resolveMainWindowBlurSurface } from './main-window-blur-surface' + +const PLATFORMS: NodeJS.Platform[] = ['darwin', 'win32', 'linux'] + +describe('resolveMainWindowBlurSurface (#8797)', () => { + it('keeps the opaque fill and no material when blur is off (#8482 default path)', () => { + for (const platform of PLATFORMS) { + expect(resolveMainWindowBlurSurface({ platform, blur: false, dark: true })).toEqual({ + backgroundColor: '#0a0a0a', + blurOptions: {} + }) + expect(resolveMainWindowBlurSurface({ platform, blur: false, dark: false })).toEqual({ + backgroundColor: '#ffffff', + blurOptions: {} + }) + } + }) + + it('drops the opaque fill so macOS vibrancy is the window backdrop', () => { + for (const dark of [true, false]) { + const surface = resolveMainWindowBlurSurface({ platform: 'darwin', blur: true, dark }) + expect(surface.backgroundColor).toBeUndefined() + expect(surface.blurOptions).toEqual({ vibrancy: 'under-window', visualEffectState: 'active' }) + } + }) + + it('drops the opaque fill so Windows acrylic is visible', () => { + const surface = resolveMainWindowBlurSurface({ platform: 'win32', blur: true, dark: true }) + expect(surface.backgroundColor).toBeUndefined() + expect(surface.blurOptions).toEqual({ backgroundMaterial: 'acrylic' }) + }) + + it('leaves Linux opaque because it has no supported blur material', () => { + expect(resolveMainWindowBlurSurface({ platform: 'linux', blur: true, dark: true })).toEqual({ + backgroundColor: '#0a0a0a', + blurOptions: {} + }) + }) + + it('never requests window transparency (#8482)', () => { + for (const platform of PLATFORMS) { + for (const blur of [true, false]) { + const surface = resolveMainWindowBlurSurface({ platform, blur, dark: true }) + expect(surface.blurOptions).not.toHaveProperty('transparent') + expect(surface.backgroundColor).not.toBe('#00000000') + } + } + }) +}) diff --git a/src/main/window/main-window-blur-surface.ts b/src/main/window/main-window-blur-surface.ts new file mode 100644 index 00000000000..b872f011eef --- /dev/null +++ b/src/main/window/main-window-blur-surface.ts @@ -0,0 +1,42 @@ +/** + * Window-creation options that decide whether the platform blur material has a surface to render on. + * + * Why: an always-opaque `backgroundColor` paints over macOS vibrancy / Windows acrylic, so Window Blur + * and terminal Background Opacity both looked like no-ops (#8797). `transparent` is deliberately never + * set: it is what suppressed vibrancy and took the window off the opaque fast path (#8482). + */ + +export type MainWindowBlurSurface = { + /** Omitted when a blur material owns the window backdrop; an opaque fill would hide it. */ + backgroundColor?: string + /** Spread into the BrowserWindow constructor. */ + blurOptions: { + vibrancy?: 'under-window' + visualEffectState?: 'active' + backgroundMaterial?: 'acrylic' + } +} + +export function resolveMainWindowBlurSurface(input: { + platform: NodeJS.Platform + blur: boolean + dark: boolean +}): MainWindowBlurSurface { + const opaqueBackgroundColor = input.dark ? '#0a0a0a' : '#ffffff' + + if (!input.blur) { + return { backgroundColor: opaqueBackgroundColor, blurOptions: {} } + } + + if (input.platform === 'darwin') { + // Why 'active': otherwise macOS freezes the material to a static snapshot whenever the window is unfocused. + return { blurOptions: { vibrancy: 'under-window', visualEffectState: 'active' } } + } + + if (input.platform === 'win32') { + return { blurOptions: { backgroundMaterial: 'acrylic' } } + } + + // Linux has no supported material; a clear (unblurred) window would be worse than an opaque one. + return { backgroundColor: opaqueBackgroundColor, blurOptions: {} } +} diff --git a/src/renderer/src/App.tsx b/src/renderer/src/App.tsx index 9874acecd66..0a049b53689 100644 --- a/src/renderer/src/App.tsx +++ b/src/renderer/src/App.tsx @@ -32,6 +32,7 @@ import { import { resolveLeftTitlebarChromeLayout } from '@/lib/titlebar-left-chrome' import { shouldShowWorktreeCreationSurface } from '@/lib/worktree-creation-surface' import { buildAppFontFamily } from '@/lib/app-font-family' +import { applyWindowBlurRootClass } from '@/lib/window-blur-root-class' import { toast } from 'sonner' import { Toaster } from '@/components/ui/sonner' import { Tooltip, TooltipContent, TooltipProvider, TooltipTrigger } from '@/components/ui/tooltip' @@ -1474,6 +1475,11 @@ function App(): React.JSX.Element { ) }, [settings?.appFontFamily]) + // Why: vibrancy/acrylic sit behind the web contents; an opaque app root hides them (#8797). + useEffect(() => { + applyWindowBlurRootClass(document.documentElement, settings?.windowBackgroundBlur ?? false) + }, [settings?.windowBackgroundBlur]) + // Refresh GitHub data (PR/issue status) when window regains focus useEffect(() => { const handler = (): void => { diff --git a/src/renderer/src/assets/main.css b/src/renderer/src/assets/main.css index 85d6a2c116a..c90a2e688c8 100644 --- a/src/renderer/src/assets/main.css +++ b/src/renderer/src/assets/main.css @@ -698,6 +698,16 @@ html.native-shell .app-layout { height: 100%; } +/* Why: macOS vibrancy / Windows acrylic render behind the web contents, so the app root must not + paint an opaque fill over them (#8797). Native-shell only — a browser tab has no material behind + it, so clearing the fill there would just leak the page background. Chrome keeps its own tokens. */ +html.native-shell.window-blur, +html.native-shell.window-blur body, +html.native-shell.window-blur #root, +html.native-shell.window-blur .app-layout { + background: transparent; +} + /* Tab-group split resize handle — wide hit area, visible center line. */ .tab-group-split-resize-handle { flex-shrink: 0; diff --git a/src/renderer/src/lib/window-blur-root-class.test.ts b/src/renderer/src/lib/window-blur-root-class.test.ts new file mode 100644 index 00000000000..f9e6a378515 --- /dev/null +++ b/src/renderer/src/lib/window-blur-root-class.test.ts @@ -0,0 +1,38 @@ +/** + * @vitest-environment happy-dom + */ +import { readFileSync } from 'node:fs' +import { join } from 'node:path' +import { describe, expect, it } from 'vitest' +import { applyWindowBlurRootClass, WINDOW_BLUR_ROOT_CLASS } from './window-blur-root-class' + +function readSource(relativePath: string): string { + return readFileSync(join(process.cwd(), relativePath), 'utf8') +} + +describe('window blur root class (#8797)', () => { + it('toggles the root class from the setting', () => { + const root = document.documentElement + + applyWindowBlurRootClass(root, true) + expect(root.classList.contains(WINDOW_BLUR_ROOT_CLASS)).toBe(true) + + applyWindowBlurRootClass(root, false) + expect(root.classList.contains(WINDOW_BLUR_ROOT_CLASS)).toBe(false) + }) + + it('clears the native-shell app root fill so the platform blur material shows through', () => { + expect(readSource('src/renderer/src/assets/main.css')).toMatch( + /html\.native-shell\.window-blur,\s*html\.native-shell\.window-blur body,\s*html\.native-shell\.window-blur #root,\s*html\.native-shell\.window-blur \.app-layout\s*\{\s*background: transparent;\s*\}/ + ) + }) + + it('drives the class from windowBackgroundBlur in App', () => { + const appSource = readSource('src/renderer/src/App.tsx') + + expect(appSource).toMatch( + /applyWindowBlurRootClass\(\s*document\.documentElement,\s*settings\?\.windowBackgroundBlur \?\? false\s*\)/ + ) + expect(appSource).toContain('}, [settings?.windowBackgroundBlur])') + }) +}) diff --git a/src/renderer/src/lib/window-blur-root-class.ts b/src/renderer/src/lib/window-blur-root-class.ts new file mode 100644 index 00000000000..09a6623fc5d --- /dev/null +++ b/src/renderer/src/lib/window-blur-root-class.ts @@ -0,0 +1,6 @@ +/** Root class that drops the app's opaque fill so the platform blur material behind the web contents shows (#8797). */ +export const WINDOW_BLUR_ROOT_CLASS = 'window-blur' + +export function applyWindowBlurRootClass(root: HTMLElement, enabled: boolean): void { + root.classList.toggle(WINDOW_BLUR_ROOT_CLASS, enabled) +}