Skip to content

Commit e3db009

Browse files
Merge pull request #155 from laststance/codex/update-deps-electron-opacity
Add Electron 42 window blur appearance setting
2 parents cd4f322 + b872bc8 commit e3db009

18 files changed

Lines changed: 819 additions & 267 deletions

.storybook/fixtures.ts

Lines changed: 2 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,4 @@
1-
import type { Settings } from '@/shared/settings'
1+
import { DEFAULT_SETTINGS, type Settings } from '@/shared/settings'
22
import {
33
repositoryId,
44
semanticVersion,
@@ -330,8 +330,7 @@ export const storySyncResult: SyncExecuteResult = {
330330
}
331331

332332
export const storySettings: Settings = {
333-
defaultSkillTab: 'files',
334-
preferredTerminal: 'terminal',
333+
...DEFAULT_SETTINGS,
335334
hiddenAgentIds: ['cursor'],
336335
}
337336

package.json

Lines changed: 6 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -55,7 +55,7 @@
5555
"remark-gfm": "^4.0.1",
5656
"shiki": "^4.0.2",
5757
"sonner": "^2.0.7",
58-
"tailwind-merge": "^3.5.0",
58+
"tailwind-merge": "^3.6.0",
5959
"ts-pattern": "^5.9.0",
6060
"zod": "^4.4.3"
6161
},
@@ -72,8 +72,8 @@
7272
"@types/react": "^19.2.14",
7373
"@types/react-dom": "^19.2.3",
7474
"@vitejs/plugin-react": "^6.0.1",
75-
"@vitest/browser-playwright": "^4.1.5",
76-
"@vitest/coverage-v8": "4.1.5",
75+
"@vitest/browser-playwright": "^4.1.6",
76+
"@vitest/coverage-v8": "^4.1.6",
7777
"code-inspector-plugin": "^1.5.1",
7878
"culori": "^4.0.2",
7979
"electron": "^42.0.1",
@@ -84,16 +84,16 @@
8484
"eslint-plugin-react-you-might-not-need-an-effect": "^0.10.1",
8585
"fallow": "2.69.0",
8686
"husky": "^9.1.7",
87-
"lint-staged": "^17.0.3",
87+
"lint-staged": "^17.0.4",
8888
"npm-run-all2": "^8.0.4",
8989
"playwright": "^1.59.1",
9090
"prettier": "^3.8.3",
9191
"sharp": "^0.34.5",
9292
"storybook": "10.3.6",
9393
"tailwindcss": "^4.3.0",
9494
"typescript": "^6.0.3",
95-
"vite": "^8.0.11",
96-
"vitest": "^4.1.5",
95+
"vite": "^8.0.12",
96+
"vitest": "^4.1.6",
9797
"vitest-browser-react": "^2.2.0"
9898
},
9999
"lint-staged": {

pnpm-lock.yaml

Lines changed: 203 additions & 203 deletions
Some generated files are not rendered by default. Learn more about customizing how changed files appear on GitHub.

src/main/index.ts

Lines changed: 11 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -21,6 +21,10 @@ import { attachExternalLinkHandler } from './utils/attachExternalLinkHandler'
2121
import { clampSizeToWorkArea } from './utils/clampSizeToWorkArea'
2222
import { isE2EBackgroundLaunch } from './utils/e2eEnv'
2323
import { getSecureWebPreferences } from './utils/secureWebPreferences'
24+
import {
25+
applyWindowBackgroundBlur,
26+
MAIN_WINDOW_OPAQUE_BACKGROUND,
27+
} from './utils/windowBackgroundBlur'
2428

2529
process.on('unhandledRejection', (reason, promise) => {
2630
console.error('Unhandled Rejection at:', promise, 'reason:', reason)
@@ -53,13 +57,14 @@ const DEFAULT_LAUNCH_WIDTH = 1200
5357
const DEFAULT_LAUNCH_HEIGHT = 800
5458

5559
function createWindow(): void {
60+
const settings = getSettings()
5661
// Resolve the launch size from settings. `undefined` means the user has
5762
// not chosen one — fall back to the default size + `maximize()` on
5863
// ready-to-show. When set, we clamp to the current display's work area
5964
// so a size saved on a wider monitor doesn't open off-screen on a smaller
6065
// one (clamping happens *before* the BrowserWindow is constructed because
6166
// Electron applies `width`/`height` literally — there's no built-in clamp).
62-
const persistedWindowSize = getSettings().windowSize
67+
const persistedWindowSize = settings.windowSize
6368
const primaryWorkArea = screen.getPrimaryDisplay().workAreaSize
6469
const hasCustomSize = persistedWindowSize !== undefined
6570
const launchSize = hasCustomSize
@@ -80,7 +85,10 @@ function createWindow(): void {
8085
minWidth: 800,
8186
minHeight: 600,
8287
show: false,
83-
backgroundColor: '#0A0F1C',
88+
backgroundColor: MAIN_WINDOW_OPAQUE_BACKGROUND,
89+
// Required for the renderer root and Electron contentView alpha channel
90+
// to reveal the native background blur when the Appearance slider is on.
91+
transparent: true,
8492
titleBarStyle: 'hiddenInset',
8593
trafficLightPosition: { x: 16, y: 16 },
8694
webPreferences: {
@@ -90,6 +98,7 @@ function createWindow(): void {
9098
webviewTag: true,
9199
},
92100
})
101+
applyWindowBackgroundBlur(window, settings.windowBackgroundBlurRadius)
93102
setMainWindow(window)
94103

95104
window.on('closed', () => {

src/main/ipc/ipc-schemas.test.ts

Lines changed: 36 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,10 @@
11
import { describe, expect, it } from 'vitest'
22

3+
import {
4+
WINDOW_BACKGROUND_BLUR_MAX_RADIUS,
5+
WINDOW_BACKGROUND_BLUR_MIN_RADIUS,
6+
} from '@/shared/settings'
7+
38
import { IPC_ARG_SCHEMAS } from './ipc-schemas'
49

510
/**
@@ -107,6 +112,12 @@ describe('settings:set lockstep with SettingsSchema', () => {
107112
)
108113
})
109114

115+
it('accepts the bounded windowBackgroundBlurRadius field', () => {
116+
expect(schema.safeParse([{ windowBackgroundBlurRadius: 24 }]).success).toBe(
117+
true,
118+
)
119+
})
120+
110121
it('rejects an unknown enum value for preferredTerminal', () => {
111122
expect(
112123
schema.safeParse([{ preferredTerminal: 'fish-shell' }]).success,
@@ -119,6 +130,26 @@ describe('settings:set lockstep with SettingsSchema', () => {
119130
).toBe(false)
120131
})
121132

133+
it('rejects an invalid windowBackgroundBlurRadius value', () => {
134+
expect(
135+
schema.safeParse([
136+
{
137+
windowBackgroundBlurRadius: WINDOW_BACKGROUND_BLUR_MIN_RADIUS - 1,
138+
},
139+
]).success,
140+
).toBe(false)
141+
expect(
142+
schema.safeParse([{ windowBackgroundBlurRadius: 24.5 }]).success,
143+
).toBe(false)
144+
expect(
145+
schema.safeParse([
146+
{
147+
windowBackgroundBlurRadius: WINDOW_BACKGROUND_BLUR_MAX_RADIUS + 1,
148+
},
149+
]).success,
150+
).toBe(false)
151+
})
152+
122153
it('rejects unknown extra keys (.strict())', () => {
123154
expect(
124155
schema.safeParse([{ defaultSkillTab: 'files', somethingElse: 'x' }])
@@ -138,6 +169,11 @@ describe('settings:set lockstep with SettingsSchema', () => {
138169
expect('hiddenAgentIds' in parsed[0]).toBe(false)
139170
})
140171

172+
it('parsing a partial without windowBackgroundBlurRadius does NOT inject a default', () => {
173+
const parsed = schema.parse([{ defaultSkillTab: 'info' }]) as [object]
174+
expect('windowBackgroundBlurRadius' in parsed[0]).toBe(false)
175+
})
176+
141177
it('accepts an explicit hiddenAgentIds array on settings:set', () => {
142178
expect(
143179
schema.safeParse([{ hiddenAgentIds: ['claude-code'] }]).success,

src/main/ipc/ipc-schemas.ts

Lines changed: 8 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -2,7 +2,10 @@ import { z } from 'zod'
22

33
import { AGENT_IDS, TERMINAL_APP_IDS } from '@/shared/constants'
44
import type { IpcInvokeChannel } from '@/shared/ipc-contract'
5-
import { SettingsSchema } from '@/shared/settings'
5+
import {
6+
SettingsSchema,
7+
WINDOW_BACKGROUND_BLUR_RADIUS_SCHEMA,
8+
} from '@/shared/settings'
69

710
/**
811
* Zod schemas for runtime validation of IPC invoke arguments.
@@ -281,6 +284,10 @@ export const IPC_ARG_SCHEMAS: Partial<Record<IpcInvokeChannel, z.ZodTuple>> = {
281284
// the {min,int} constraints can never drift. `undefined` is how
282285
// the Settings UI clears the persisted size back to "use default".
283286
windowSize: SettingsSchema.shape.windowSize,
287+
// Electron 42 blur radius. Use the shared non-defaulting schema so
288+
// unrelated partial settings writes do not reset blur to zero.
289+
windowBackgroundBlurRadius:
290+
WINDOW_BACKGROUND_BLUR_RADIUS_SCHEMA.optional(),
284291
// Strict z.enum here — renderers should only ever emit valid ids.
285292
// Intentionally NOT chained off `SettingsSchema.shape.hiddenAgentIds`:
286293
// that field carries a `.default([])` for forgiving disk reads, and

src/main/ipc/settings.ts

Lines changed: 12 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,7 @@
1+
import { getMainWindow } from '@/main/services/mainWindowState'
12
import { getSettings, saveSettings } from '@/main/services/settings'
23
import { createOrFocusSettingsWindow } from '@/main/services/settingsWindow'
4+
import { applyWindowBackgroundBlur } from '@/main/utils/windowBackgroundBlur'
35
import { IPC_CHANNELS } from '@/shared/ipc-channels'
46

57
import { typedHandle } from './typedHandle'
@@ -35,6 +37,16 @@ export function registerSettingsHandlers(): void {
3537
// broadcast in that case so we don't fan out a no-op `settings:changed`
3638
// and trigger a redundant Redux replace in every open window.
3739
if (next !== before) {
40+
if (
41+
next.windowBackgroundBlurRadius !== before.windowBackgroundBlurRadius
42+
) {
43+
const mainWindow = getMainWindow()
44+
// Settings can outlive the main window on macOS; a closed main window
45+
// simply receives the persisted blur the next time it is created.
46+
if (mainWindow !== null) {
47+
applyWindowBackgroundBlur(mainWindow, next.windowBackgroundBlurRadius)
48+
}
49+
}
3850
broadcastTypedEvent(IPC_CHANNELS.SETTINGS_CHANGED, next)
3951
}
4052
return next

src/main/services/settings.test.ts

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -16,6 +16,7 @@ describe('areSettingsEqual', () => {
1616
const baseSettings: Settings = {
1717
defaultSkillTab: 'files',
1818
preferredTerminal: 'terminal',
19+
windowBackgroundBlurRadius: 0,
1920
hiddenAgentIds: [],
2021
}
2122

Lines changed: 115 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,115 @@
1+
import type { BrowserWindow } from 'electron'
2+
import { describe, expect, it, vi } from 'vitest'
3+
4+
import { WINDOW_BACKGROUND_BLUR_MAX_RADIUS } from '@/shared/settings'
5+
6+
import {
7+
applyWindowBackgroundBlur,
8+
MAIN_WINDOW_BLURRED_BACKGROUND,
9+
getMainWindowBackgroundColor,
10+
MAIN_WINDOW_OPAQUE_BACKGROUND,
11+
normalizeWindowBackgroundBlurRadius,
12+
} from './windowBackgroundBlur'
13+
14+
/**
15+
* Build the minimal BrowserWindow/contentView surface the blur mutator uses.
16+
* @param supportsBlur - Whether the mocked contentView exposes Electron 42 blur.
17+
* @param supportsViewBackgroundColor - Whether contentView background updates exist.
18+
* @returns Mock window plus spies for assertions.
19+
* @example
20+
* const { window } = makeWindowMock(true)
21+
* applyWindowBackgroundBlur(window, 12)
22+
*/
23+
function makeWindowMock(
24+
supportsBlur: boolean,
25+
supportsViewBackgroundColor = true,
26+
) {
27+
const contentView = {
28+
...(supportsViewBackgroundColor ? { setBackgroundColor: vi.fn() } : {}),
29+
...(supportsBlur ? { setBackgroundBlur: vi.fn() } : {}),
30+
}
31+
const window = {
32+
setBackgroundColor: vi.fn(),
33+
contentView,
34+
} as unknown as BrowserWindow
35+
return { window, contentView }
36+
}
37+
38+
/**
39+
* Pure helpers around Electron 42 background blur. The BrowserWindow mutator
40+
* itself is covered by integration/e2e; these tests pin the clamping and alpha
41+
* color contract before values reach Electron.
42+
*/
43+
describe('windowBackgroundBlur helpers', () => {
44+
it('clamps persisted blur radius values to the supported range', () => {
45+
expect(normalizeWindowBackgroundBlurRadius(-12)).toBe(0)
46+
expect(normalizeWindowBackgroundBlurRadius(12.9)).toBe(12)
47+
expect(normalizeWindowBackgroundBlurRadius(99)).toBe(
48+
WINDOW_BACKGROUND_BLUR_MAX_RADIUS,
49+
)
50+
})
51+
52+
it('uses an opaque background when blur is disabled', () => {
53+
expect(getMainWindowBackgroundColor(0)).toBe(MAIN_WINDOW_OPAQUE_BACKGROUND)
54+
})
55+
56+
it('uses an alpha background when blur is enabled', () => {
57+
expect(getMainWindowBackgroundColor(12)).toBe(
58+
MAIN_WINDOW_BLURRED_BACKGROUND,
59+
)
60+
})
61+
62+
it('applies opaque color and zero blur when the radius is disabled', () => {
63+
const { window, contentView } = makeWindowMock(true)
64+
65+
applyWindowBackgroundBlur(window, 0)
66+
67+
expect(window.setBackgroundColor).toHaveBeenCalledWith(
68+
MAIN_WINDOW_OPAQUE_BACKGROUND,
69+
)
70+
expect(contentView.setBackgroundColor).toHaveBeenCalledWith(
71+
MAIN_WINDOW_OPAQUE_BACKGROUND,
72+
)
73+
expect(contentView.setBackgroundBlur).toHaveBeenCalledWith(0)
74+
})
75+
76+
it('applies alpha color and clamped blur when Electron 42 blur is available', () => {
77+
const { window, contentView } = makeWindowMock(true)
78+
79+
applyWindowBackgroundBlur(window, WINDOW_BACKGROUND_BLUR_MAX_RADIUS + 1)
80+
81+
expect(window.setBackgroundColor).toHaveBeenCalledWith(
82+
MAIN_WINDOW_BLURRED_BACKGROUND,
83+
)
84+
expect(contentView.setBackgroundColor).toHaveBeenCalledWith(
85+
MAIN_WINDOW_BLURRED_BACKGROUND,
86+
)
87+
expect(contentView.setBackgroundBlur).toHaveBeenCalledWith(
88+
WINDOW_BACKGROUND_BLUR_MAX_RADIUS,
89+
)
90+
})
91+
92+
it('skips blur safely when Electron exposes no setBackgroundBlur method', () => {
93+
const { window, contentView } = makeWindowMock(false)
94+
95+
expect(() => applyWindowBackgroundBlur(window, 12)).not.toThrow()
96+
expect(window.setBackgroundColor).toHaveBeenCalledWith(
97+
MAIN_WINDOW_BLURRED_BACKGROUND,
98+
)
99+
expect(contentView.setBackgroundColor).toHaveBeenCalledWith(
100+
MAIN_WINDOW_BLURRED_BACKGROUND,
101+
)
102+
expect('setBackgroundBlur' in contentView).toBe(false)
103+
})
104+
105+
it('skips contentView background safely when the method is absent', () => {
106+
const { window, contentView } = makeWindowMock(true, false)
107+
108+
expect(() => applyWindowBackgroundBlur(window, 12)).not.toThrow()
109+
expect(window.setBackgroundColor).toHaveBeenCalledWith(
110+
MAIN_WINDOW_BLURRED_BACKGROUND,
111+
)
112+
expect('setBackgroundColor' in contentView).toBe(false)
113+
expect(contentView.setBackgroundBlur).toHaveBeenCalledWith(12)
114+
})
115+
})

0 commit comments

Comments
 (0)