Skip to content

Commit 43f563b

Browse files
fix: make appearance opacity visible
1 parent 069e11d commit 43f563b

9 files changed

Lines changed: 223 additions & 49 deletions

File tree

src/main/utils/windowBackgroundBlur.test.ts

Lines changed: 7 additions & 10 deletions
Original file line numberDiff line numberDiff line change
@@ -5,7 +5,6 @@ import { WINDOW_BACKGROUND_BLUR_MAX_RADIUS } from '@/shared/settings'
55

66
import {
77
applyWindowBackgroundBlur,
8-
MAIN_WINDOW_BLURRED_BACKGROUND,
98
getMainWindowBackgroundColor,
109
MAIN_WINDOW_OPAQUE_BACKGROUND,
1110
normalizeWindowBackgroundBlurRadius,
@@ -53,10 +52,8 @@ describe('windowBackgroundBlur helpers', () => {
5352
expect(getMainWindowBackgroundColor(0)).toBe(MAIN_WINDOW_OPAQUE_BACKGROUND)
5453
})
5554

56-
it('uses an alpha background when blur is enabled', () => {
57-
expect(getMainWindowBackgroundColor(12)).toBe(
58-
MAIN_WINDOW_BLURRED_BACKGROUND,
59-
)
55+
it('uses slider-derived alpha background when blur is enabled', () => {
56+
expect(getMainWindowBackgroundColor(12)).toBe('rgba(10, 15, 28, 0.92)')
6057
})
6158

6259
it('applies opaque color and zero blur when the radius is disabled', () => {
@@ -79,10 +76,10 @@ describe('windowBackgroundBlur helpers', () => {
7976
applyWindowBackgroundBlur(window, WINDOW_BACKGROUND_BLUR_MAX_RADIUS + 1)
8077

8178
expect(window.setBackgroundColor).toHaveBeenCalledWith(
82-
MAIN_WINDOW_BLURRED_BACKGROUND,
79+
'rgba(10, 15, 28, 0.68)',
8380
)
8481
expect(contentView.setBackgroundColor).toHaveBeenCalledWith(
85-
MAIN_WINDOW_BLURRED_BACKGROUND,
82+
'rgba(10, 15, 28, 0.68)',
8683
)
8784
expect(contentView.setBackgroundBlur).toHaveBeenCalledWith(
8885
WINDOW_BACKGROUND_BLUR_MAX_RADIUS,
@@ -94,10 +91,10 @@ describe('windowBackgroundBlur helpers', () => {
9491

9592
expect(() => applyWindowBackgroundBlur(window, 12)).not.toThrow()
9693
expect(window.setBackgroundColor).toHaveBeenCalledWith(
97-
MAIN_WINDOW_BLURRED_BACKGROUND,
94+
'rgba(10, 15, 28, 0.92)',
9895
)
9996
expect(contentView.setBackgroundColor).toHaveBeenCalledWith(
100-
MAIN_WINDOW_BLURRED_BACKGROUND,
97+
'rgba(10, 15, 28, 0.92)',
10198
)
10299
expect('setBackgroundBlur' in contentView).toBe(false)
103100
})
@@ -107,7 +104,7 @@ describe('windowBackgroundBlur helpers', () => {
107104

108105
expect(() => applyWindowBackgroundBlur(window, 12)).not.toThrow()
109106
expect(window.setBackgroundColor).toHaveBeenCalledWith(
110-
MAIN_WINDOW_BLURRED_BACKGROUND,
107+
'rgba(10, 15, 28, 0.92)',
111108
)
112109
expect('setBackgroundColor' in contentView).toBe(false)
113110
expect(contentView.setBackgroundBlur).toHaveBeenCalledWith(12)

src/main/utils/windowBackgroundBlur.ts

Lines changed: 11 additions & 24 deletions
Original file line numberDiff line numberDiff line change
@@ -1,8 +1,8 @@
11
import type { BrowserWindow, View } from 'electron'
22

33
import {
4-
WINDOW_BACKGROUND_BLUR_MAX_RADIUS,
5-
WINDOW_BACKGROUND_BLUR_MIN_RADIUS,
4+
getWindowBackgroundOpacity,
5+
normalizeWindowBackgroundBlurRadius,
66
} from '@/shared/settings'
77

88
/**
@@ -13,43 +13,30 @@ import {
1313
export const MAIN_WINDOW_OPAQUE_BACKGROUND = 'rgb(10, 15, 28)'
1414

1515
/**
16-
* Alpha mirrors the renderer's `bg-background/85` class so Chromium and the
17-
* native Electron contentView expose the same glass strength.
16+
* RGB channels for the dark app canvas used by Electron before renderer CSS
17+
* is available. The renderer uses the OKLCH token equivalent.
1818
*/
19-
export const MAIN_WINDOW_BLURRED_BACKGROUND = 'rgba(10, 15, 28, 0.85)'
19+
export const MAIN_WINDOW_BACKGROUND_RGB_CHANNELS = '10, 15, 28'
2020

2121
type BackgroundBlurCapableView = View & {
2222
setBackgroundBlur?: (blurRadius: number) => void
2323
}
2424

25-
/**
26-
* Clamp a persisted blur radius before it touches Electron APIs.
27-
* @param blurRadius - User setting from `settings.json` or IPC.
28-
* @returns Whole-pixel radius inside the app-supported range.
29-
* @example
30-
* normalizeWindowBackgroundBlurRadius(99) // => 48
31-
*/
32-
export function normalizeWindowBackgroundBlurRadius(
33-
blurRadius: number,
34-
): number {
35-
return Math.min(
36-
WINDOW_BACKGROUND_BLUR_MAX_RADIUS,
37-
Math.max(WINDOW_BACKGROUND_BLUR_MIN_RADIUS, Math.trunc(blurRadius)),
38-
)
39-
}
25+
export { normalizeWindowBackgroundBlurRadius } from '@/shared/settings'
4026

4127
/**
4228
* Pick the window backplate color required by Electron's blur renderer.
4329
* @param blurRadius - Normalized or raw blur radius.
44-
* @returns Opaque color when blur is off; alpha color when blur is on.
30+
* @returns Opaque color when blur is off; slider-derived alpha when blur is on.
4531
* @example
46-
* getMainWindowBackgroundColor(12) // => 'rgba(10, 15, 28, 0.82)'
32+
* getMainWindowBackgroundColor(48) // => 'rgba(10, 15, 28, 0.68)'
4733
*/
4834
export function getMainWindowBackgroundColor(blurRadius: number): string {
4935
const normalizedRadius = normalizeWindowBackgroundBlurRadius(blurRadius)
50-
// Electron only shows `setBackgroundBlur` through an alpha background.
5136
if (normalizedRadius > 0) {
52-
return MAIN_WINDOW_BLURRED_BACKGROUND
37+
const opacity = getWindowBackgroundOpacity(normalizedRadius)
38+
// Electron only shows native blur through an alpha BrowserWindow backplate.
39+
return `rgba(${MAIN_WINDOW_BACKGROUND_RGB_CHANNELS}, ${opacity})`
5340
}
5441
return MAIN_WINDOW_OPAQUE_BACKGROUND
5542
}

src/renderer/settings/sections/Appearance.browser.test.tsx

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -53,6 +53,7 @@ describe('Settings → Appearance', () => {
5353
const slider = screen.getByRole('slider', { name: /Opacity/i })
5454
await slider.fill('24')
5555

56+
await expect.element(screen.getByText('84% / 24px')).toBeVisible()
5657
await expect.poll(() => mockSettingsSet.mock.calls.length).toBe(1)
5758
expect(mockSettingsSet).toHaveBeenCalledWith({
5859
windowBackgroundBlurRadius: 24,

src/renderer/settings/sections/Appearance.tsx

Lines changed: 10 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -7,6 +7,7 @@ import { useUpdateSettings } from '@/renderer/src/hooks/useUpdateSettings'
77
import { useAppSelector } from '@/renderer/src/redux/hooks'
88
import {
99
DEFAULT_SETTINGS,
10+
getWindowBackgroundOpacity,
1011
WINDOW_BACKGROUND_BLUR_MAX_RADIUS,
1112
WINDOW_BACKGROUND_BLUR_MIN_RADIUS,
1213
} from '@/shared/settings'
@@ -61,10 +62,9 @@ const BackgroundBlurRangeInput = React.memo(function BackgroundBlurRangeInput({
6162
/**
6263
* Appearance pane for visual controls backed by persisted Settings.
6364
*
64-
* The first real control is the Electron 42 background blur radius. It
65-
* updates main-process settings through the same optimistic IPC path as
66-
* General → default tab, so the main window reacts immediately while the
67-
* value remains durable across launches.
65+
* The first real control is the Electron 42 background blur radius. The same
66+
* slider also lowers the app-surface opacity, so users see an immediate
67+
* opacity/blur change instead of a fixed 85% backplate.
6868
*/
6969
export const Appearance = React.memo(function Appearance(): React.ReactElement {
7070
const windowBackgroundBlurRadius = useAppSelector(
@@ -118,10 +118,13 @@ export const Appearance = React.memo(function Appearance(): React.ReactElement {
118118
clearPersistTimer()
119119
})
120120

121+
const backgroundOpacityPercent = Math.round(
122+
getWindowBackgroundOpacity(blurRadiusDraft) * 100,
123+
)
121124
const blurRadiusLabel =
122125
blurRadiusDraft === WINDOW_BACKGROUND_BLUR_MIN_RADIUS
123-
? 'Off'
124-
: `${blurRadiusDraft}px`
126+
? 'Opaque'
127+
: `${backgroundOpacityPercent}% / ${blurRadiusDraft}px`
125128
const isDefaultBlurRadius =
126129
blurRadiusDraft === DEFAULT_SETTINGS.windowBackgroundBlurRadius
127130

@@ -132,7 +135,7 @@ export const Appearance = React.memo(function Appearance(): React.ReactElement {
132135
>
133136
<SectionRow
134137
label={BACKGROUND_BLUR_LABEL}
135-
description="Background glass strength for the main window."
138+
description="Surface opacity and background blur for the main window."
136139
>
137140
<div className="flex max-w-md flex-col gap-2">
138141
<div className="flex items-center gap-3">
Lines changed: 97 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,97 @@
1+
import { configureStore } from '@reduxjs/toolkit'
2+
import type React from 'react'
3+
import { Provider } from 'react-redux'
4+
import { describe, expect, it, vi } from 'vitest'
5+
import { render } from 'vitest-browser-react'
6+
7+
import '@/renderer/src/styles/globals.css'
8+
import { DEFAULT_SETTINGS } from '@/shared/settings'
9+
10+
import settingsReducer from './redux/slices/settingsSlice'
11+
import themeReducer from './redux/slices/themeSlice'
12+
13+
vi.mock('react-resizable-panels', () => ({
14+
Group: ({ children }: { children: React.ReactNode }) => <div>{children}</div>,
15+
Panel: ({ children }: { children: React.ReactNode }) => <div>{children}</div>,
16+
Separator: ({ className }: { className?: string }) => (
17+
<div className={className} />
18+
),
19+
}))
20+
21+
vi.mock('sonner', () => ({
22+
Toaster: () => null,
23+
}))
24+
25+
vi.mock('./components/layout/DetailPanel', () => ({
26+
DetailPanel: () => <div data-testid="detail-panel" />,
27+
}))
28+
29+
vi.mock('./components/layout/MainContent', () => ({
30+
MainContent: () => <main data-testid="main-content" />,
31+
}))
32+
33+
vi.mock('./components/layout/Sidebar', () => ({
34+
Sidebar: () => <aside data-testid="sidebar" />,
35+
}))
36+
37+
vi.mock('./components/UpdateToast', () => ({
38+
UpdateToast: () => null,
39+
}))
40+
41+
vi.mock('./components/ui/tooltip', () => ({
42+
TooltipProvider: ({ children }: { children: React.ReactNode }) => (
43+
<>{children}</>
44+
),
45+
}))
46+
47+
vi.mock('./hooks/useReleaseNotesToast', () => ({
48+
useReleaseNotesToast: vi.fn(),
49+
}))
50+
51+
vi.mock('./hooks/useSettingsSync', () => ({
52+
useSettingsSync: vi.fn(),
53+
}))
54+
55+
vi.mock('./hooks/useUpdateNotification', () => ({
56+
useUpdateNotification: vi.fn(),
57+
}))
58+
59+
/**
60+
* Render App with only the slices it reads directly. Heavy child panels are
61+
* mocked so this test can focus on the window-surface opacity contract.
62+
* @param windowBackgroundBlurRadius - Slider value persisted in settings.
63+
* @returns Browser test screen for the rendered shell.
64+
* @example
65+
* renderAppWithBlur(24)
66+
*/
67+
async function renderAppWithBlur(windowBackgroundBlurRadius: number) {
68+
const { default: App } = await import('./App')
69+
const store = configureStore({
70+
reducer: {
71+
settings: settingsReducer,
72+
theme: themeReducer,
73+
},
74+
preloadedState: {
75+
settings: { ...DEFAULT_SETTINGS, windowBackgroundBlurRadius },
76+
},
77+
})
78+
79+
return render(
80+
<Provider store={store}>
81+
<App />
82+
</Provider>,
83+
)
84+
}
85+
86+
describe('App window surface', () => {
87+
it('uses slider-derived opacity on the app backplate', async () => {
88+
const screen = await renderAppWithBlur(24)
89+
const surface = screen
90+
.getByTestId('window-background-surface')
91+
.element() as HTMLElement
92+
93+
expect(surface.style.backgroundColor).toBe(
94+
'oklch(from var(--background) l c h / 0.84)',
95+
)
96+
})
97+
})

src/renderer/src/App.tsx

Lines changed: 13 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -2,6 +2,8 @@ import React from 'react'
22
import { Panel, Group, Separator } from 'react-resizable-panels'
33
import { Toaster } from 'sonner'
44

5+
import { getWindowBackgroundOpacity } from '@/shared/settings'
6+
57
import { DetailPanel } from './components/layout/DetailPanel'
68
import { MainContent } from './components/layout/MainContent'
79
import { Sidebar } from './components/layout/Sidebar'
@@ -10,7 +12,6 @@ import { UpdateToast } from './components/UpdateToast'
1012
import { useReleaseNotesToast } from './hooks/useReleaseNotesToast'
1113
import { useSettingsSync } from './hooks/useSettingsSync'
1214
import { useUpdateNotification } from './hooks/useUpdateNotification'
13-
import { cn } from './lib/utils'
1415
import { useAppSelector } from './redux/hooks'
1516

1617
const separatorClass =
@@ -117,19 +118,23 @@ const App = React.memo(function App(): React.ReactElement {
117118
// Drive sonner's theme prop from the persisted redux mode so toasts honor
118119
// the user's light/dark choice. Pre-fix this was hardcoded `theme="dark"`.
119120
const mode = useAppSelector((state) => state.theme.mode)
120-
const hasWindowBackgroundBlur = useAppSelector(
121-
(state) => state.settings.windowBackgroundBlurRadius > 0,
121+
const windowBackgroundBlurRadius = useAppSelector(
122+
(state) => state.settings.windowBackgroundBlurRadius,
123+
)
124+
const windowBackgroundOpacity = getWindowBackgroundOpacity(
125+
windowBackgroundBlurRadius,
122126
)
123127

124128
return (
125129
<TooltipProvider delayDuration={200}>
126130
{/* Window glow effect - subtle inner shadow for depth */}
127131
<div
128-
className={cn(
129-
'flex h-screen text-foreground window-glow',
130-
// Keep the normal opaque surface until the user turns on blur.
131-
hasWindowBackgroundBlur ? 'bg-background/85' : 'bg-background',
132-
)}
132+
data-testid="window-background-surface"
133+
className="flex h-screen text-foreground window-glow transition-[background-color]"
134+
// Keep text opaque while the slider changes only the app backplate.
135+
style={{
136+
backgroundColor: `oklch(from var(--background) l c h / ${windowBackgroundOpacity})`,
137+
}}
133138
>
134139
<Sidebar />
135140
<Group orientation="horizontal" className="flex-1 h-full">

src/shared/settings.test.ts

Lines changed: 34 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -3,9 +3,13 @@ import { describe, it, expect } from 'vitest'
33
import { AGENT_DEFINITIONS } from './constants'
44
import {
55
DEFAULT_SETTINGS,
6+
getWindowBackgroundOpacity,
7+
normalizeWindowBackgroundBlurRadius,
68
SettingsSchema,
79
WINDOW_BACKGROUND_BLUR_MAX_RADIUS,
810
WINDOW_BACKGROUND_BLUR_MIN_RADIUS,
11+
WINDOW_BACKGROUND_OPACITY_MIN,
12+
WINDOW_BACKGROUND_OPACITY_MAX,
913
WINDOW_SIZE_MIN_DIMENSION,
1014
} from './settings'
1115

@@ -242,3 +246,33 @@ describe('SettingsSchema', () => {
242246
expect(parsed.hiddenAgentIds).toEqual([firstAgentId])
243247
})
244248
})
249+
250+
/**
251+
* Pure helpers shared by the main process and renderer. These keep Electron's
252+
* native backplate and the React app root on the same visible opacity curve.
253+
*/
254+
describe('window background appearance helpers', () => {
255+
it('clamps blur radius values before opacity math', () => {
256+
expect(normalizeWindowBackgroundBlurRadius(-12)).toBe(
257+
WINDOW_BACKGROUND_BLUR_MIN_RADIUS,
258+
)
259+
expect(normalizeWindowBackgroundBlurRadius(12.9)).toBe(12)
260+
expect(normalizeWindowBackgroundBlurRadius(99)).toBe(
261+
WINDOW_BACKGROUND_BLUR_MAX_RADIUS,
262+
)
263+
})
264+
265+
it('maps disabled blur to an opaque app surface', () => {
266+
expect(getWindowBackgroundOpacity(0)).toBe(WINDOW_BACKGROUND_OPACITY_MAX)
267+
})
268+
269+
it('maps maximum blur to the minimum readable surface opacity', () => {
270+
expect(getWindowBackgroundOpacity(WINDOW_BACKGROUND_BLUR_MAX_RADIUS)).toBe(
271+
WINDOW_BACKGROUND_OPACITY_MIN,
272+
)
273+
})
274+
275+
it('maps mid slider values to visibly different opacity', () => {
276+
expect(getWindowBackgroundOpacity(24)).toBe(0.84)
277+
})
278+
})

0 commit comments

Comments
 (0)