From 60599dea220728dd922a1b0142d247b423ac053e Mon Sep 17 00:00:00 2001 From: Serhii Vecherenko Date: Sun, 14 Jun 2026 13:42:45 -0700 Subject: [PATCH 1/2] feat(sidebar): add opt-in translucent sidebar - Thread `sidebarTranslucency` through shared settings, IPC, and window chrome schemas. - Apply native material in the main process and pre-paint the renderer to avoid opaque flashes. - Update appearance controls, sidebar shell styling, and related tests. --- src/main/ipc/localHandlers.ts | 19 ++++- src/main/main.ts | 18 +++++ src/main/sharedSettingsFile.test.ts | 2 + src/main/window/createMainWindow.test.ts | 1 + src/main/window/createMainWindow.ts | 14 +++- src/main/window/windowMaterial.ts | 43 +++++++++++ src/renderer/components/ui/provider.tsx | 49 +++++++++++- src/renderer/main.tsx | 20 +++++ src/renderer/state/sharedSettingsStore.ts | 7 ++ src/renderer/styles.css | 77 +++++++++++++++++++ .../MainView/parts/AppShell/AppShell.tsx | 4 +- .../parts/AppearanceSettings.tsx | 26 +++++++ src/shared/ipc/index.ts | 1 + src/shared/ipc/procedures/settings.ts | 16 ++-- src/shared/ipc/schemas.ts | 18 +++++ src/shared/settings.ts | 7 ++ 16 files changed, 310 insertions(+), 12 deletions(-) create mode 100644 src/main/window/windowMaterial.ts diff --git a/src/main/ipc/localHandlers.ts b/src/main/ipc/localHandlers.ts index 6f8270a7..c72bd21d 100644 --- a/src/main/ipc/localHandlers.ts +++ b/src/main/ipc/localHandlers.ts @@ -35,7 +35,9 @@ import { defineMainLocalIpcHandlers, type MainLocalIpcHandlerMap, type WindowChromePayload, + type WindowChromeResult, } from "@/shared/ipc"; +import { opaqueWindowBackground, supportsNativeWindowMaterial } from "../window/windowMaterial"; import type { LightcodePaths } from "@/shared/lightcodePaths"; import { UsageLoginManager } from "../usageLogin/UsageLoginManager"; @@ -162,10 +164,10 @@ export function createLocalIpcHandlers( options.updatePowerSaveBlocker(); options.onSharedSettingsChanged?.(); }, - setWindowChrome: async (payload: WindowChromePayload) => { + setWindowChrome: async (payload: WindowChromePayload): Promise => { const mainWindow = options.getMainWindow(); if (!mainWindow) { - return; + return { nativeMaterial: false }; } if (process.platform === "win32" || process.platform === "linux") { mainWindow.setTitleBarOverlay({ @@ -174,6 +176,19 @@ export function createLocalIpcHandlers( height: 32, }); } + // Apply (or clear) the opt-in translucent ("liquid glass") sidebar material + // live. The whole window is blurred by the OS, so the renderer keeps the + // main content opaque and leaves only the sidebar region translucent. + const nativeMaterial = payload.materialEnabled === true && supportsNativeWindowMaterial(); + if (process.platform === "darwin") { + mainWindow.setVibrancy(nativeMaterial ? "sidebar" : null); + } else if (process.platform === "win32") { + mainWindow.setBackgroundMaterial(nativeMaterial ? "acrylic" : "none"); + } + mainWindow.setBackgroundColor( + nativeMaterial ? "#00000000" : opaqueWindowBackground(payload.appearance ?? "dark"), + ); + return { nativeMaterial }; }, dbGetProjects: () => dbGetProjects(), dbGetThreads: () => dbGetThreads(), diff --git a/src/main/main.ts b/src/main/main.ts index 05770aa8..cdb03149 100644 --- a/src/main/main.ts +++ b/src/main/main.ts @@ -108,6 +108,22 @@ function resolveAppAppearance(): "light" | "dark" { return resolveThemeMode(mode, nativeTheme.shouldUseDarkColors); } +/** + * Resolves the saved opt-in translucent ("liquid glass") sidebar setting so the + * window can open with the native material already applied instead of flashing + * opaque before the renderer requests it. + */ +function resolveSidebarTranslucency(): boolean { + if (!lightcodePaths) { + return false; + } + try { + return readSharedSettingsFile(lightcodePaths.settingsPath).sidebarTranslucency === true; + } catch { + return false; + } +} + function primeBrowserAllowFlags(): void { if (!browserMcpIngress || !lightcodePaths) return; try { @@ -297,6 +313,7 @@ if (!hasSingleInstanceLock) { windowChromeHeight: WINDOW_CHROME_HEIGHT, browserUserAgent: chromeLikeUserAgent, appearance: resolveAppAppearance(), + sidebarTranslucency: resolveSidebarTranslucency(), ...(process.env.VITE_DEV_SERVER_URL ? { devServerUrl: process.env.VITE_DEV_SERVER_URL } : {}), onClosed: () => { mainWindow = null; @@ -386,6 +403,7 @@ if (!hasSingleInstanceLock) { windowChromeHeight: WINDOW_CHROME_HEIGHT, browserUserAgent: chromeLikeUserAgent, appearance: resolveAppAppearance(), + sidebarTranslucency: resolveSidebarTranslucency(), ...(process.env.VITE_DEV_SERVER_URL ? { devServerUrl: process.env.VITE_DEV_SERVER_URL } : {}), diff --git a/src/main/sharedSettingsFile.test.ts b/src/main/sharedSettingsFile.test.ts index d29374bb..a7d5f2de 100644 --- a/src/main/sharedSettingsFile.test.ts +++ b/src/main/sharedSettingsFile.test.ts @@ -69,6 +69,7 @@ describe("sharedSettingsFile", () => { threadRemoveAction: "archive", newThreadMode: "page", homeScopeEnabled: true, + sidebarTranslucency: false, autoShowTerminalPanel: true, gitReviewMode: "panel", prCreateMode: "dialog", @@ -156,6 +157,7 @@ describe("sharedSettingsFile", () => { threadRemoveAction: "archive", newThreadMode: "page", homeScopeEnabled: true, + sidebarTranslucency: false, autoShowTerminalPanel: true, gitReviewMode: "panel", prCreateMode: "dialog", diff --git a/src/main/window/createMainWindow.test.ts b/src/main/window/createMainWindow.test.ts index 02d3f80b..7849967c 100644 --- a/src/main/window/createMainWindow.test.ts +++ b/src/main/window/createMainWindow.test.ts @@ -85,6 +85,7 @@ describe("createMainWindow", () => { windowChromeHeight: 32, browserUserAgent: userAgent, appearance: "dark", + sidebarTranslucency: false, onClosed: vi.fn<() => void>(), }); diff --git a/src/main/window/createMainWindow.ts b/src/main/window/createMainWindow.ts index 3e6fc81b..ebd0accf 100644 --- a/src/main/window/createMainWindow.ts +++ b/src/main/window/createMainWindow.ts @@ -2,6 +2,7 @@ import { dbGetState, dbSetState } from "../db"; import { BrowserWindow, screen, type RenderProcessGoneDetails } from "electron"; import type { LightcodeChannel } from "@/shared/channel"; import { installSessionPermissions } from "../browser/permissions"; +import { supportsNativeWindowMaterial } from "./windowMaterial"; interface WindowBounds { x?: number; @@ -63,6 +64,8 @@ export interface CreateMainWindowOptions { browserUserAgent: string; /** Saved appearance, so the native window opens matching the theme. */ appearance: "light" | "dark"; + /** Saved opt-in translucent ("liquid glass") sidebar, so the window opens with the material already applied. */ + sidebarTranslucency: boolean; onClosed(): void; onClose?: (event: Electron.Event) => void; onRendererProcessGone?: (details: RenderProcessGoneDetails) => void; @@ -77,6 +80,11 @@ export function createMainWindow(options: CreateMainWindowOptions): BrowserWindo // setWindowChrome values, so the first frame doesn't flash a fixed palette. const backgroundColor = isDark ? "#141416" : "#f1f1f4"; const symbolColor = isDark ? "#fafafa" : "#1f2937"; + // Native translucency only when the saved toggle is on AND the OS supports a + // blur material. When active the window background is transparent so the + // OS-composited blur shows through; the renderer keeps the main content opaque + // and leaves only the sidebar region translucent (see styles.css glass rules). + const useNativeMaterial = options.sidebarTranslucency && supportsNativeWindowMaterial(); const window = new BrowserWindow({ title: options.title, show: false, @@ -85,8 +93,12 @@ export function createMainWindow(options: CreateMainWindowOptions): BrowserWindo ...(saved?.x != null && saved?.y != null ? { x: saved.x, y: saved.y } : {}), minWidth: 540, minHeight: 720, - backgroundColor, + backgroundColor: useNativeMaterial ? "#00000000" : backgroundColor, autoHideMenuBar: true, + ...(useNativeMaterial && process.platform === "darwin" ? { vibrancy: "sidebar" as const } : {}), + ...(useNativeMaterial && process.platform === "win32" + ? { backgroundMaterial: "acrylic" as const } + : {}), ...(supportsTitleBarOverlay ? { titleBarStyle: "hidden" as const, diff --git a/src/main/window/windowMaterial.ts b/src/main/window/windowMaterial.ts new file mode 100644 index 00000000..f58187e7 --- /dev/null +++ b/src/main/window/windowMaterial.ts @@ -0,0 +1,43 @@ +import { release } from "node:os"; + +/** + * Native "liquid glass" window materials. + * + * The opt-in translucent sidebar relies on an OS-composited blur behind the + * window (macOS `NSVisualEffectView` vibrancy / Windows 11 DWM acrylic). This + * module centralizes the capability check and the per-appearance opaque + * background so the window constructor ({@link createMainWindow}) and the live + * `setWindowChrome` IPC handler stay in agreement. + * + * macOS 26 "Liquid Glass" (`NSGlassEffectView`) is not exposed by Electron, so + * the closest officially-supported material is `vibrancy: "sidebar"`, which the + * OS already re-skins toward the Tahoe look. + */ + +export type Appearance = "light" | "dark"; + +/** + * Opaque window background per appearance. Mirrors the constants used by the + * constructor first paint so toggling the material off restores the same color. + */ +export function opaqueWindowBackground(appearance: Appearance): string { + return appearance === "dark" ? "#141416" : "#f1f1f4"; +} + +/** + * Windows 11 22H2 (build 22621) is the first build with a stable DWM acrylic + * system backdrop; earlier Windows builds and Windows 10 have no usable native + * blur, so they fall back to the in-app CSS imitation. + */ +function isWindows11AcrylicCapable(): boolean { + if (process.platform !== "win32") { + return false; + } + const build = Number(release().split(".")[2] ?? "0"); + return Number.isFinite(build) && build >= 22621; +} + +/** Whether the current OS can render a native blur material behind the window. */ +export function supportsNativeWindowMaterial(): boolean { + return process.platform === "darwin" || isWindows11AcrylicCapable(); +} diff --git a/src/renderer/components/ui/provider.tsx b/src/renderer/components/ui/provider.tsx index f4fa8280..c9543bd7 100644 --- a/src/renderer/components/ui/provider.tsx +++ b/src/renderer/components/ui/provider.tsx @@ -15,6 +15,14 @@ import { captureRendererException } from "@/renderer/diagnostics/sentry"; import { useSharedSettings } from "@/renderer/state/sharedSettingsStore"; import { getToastActionLabel, normalizeToastContent } from "./toastContent"; +function systemPrefersReducedTransparency(): boolean { + return ( + typeof window !== "undefined" && + typeof window.matchMedia === "function" && + window.matchMedia("(prefers-reduced-transparency: reduce)").matches + ); +} + const AppearanceContext = createContext<"light" | "dark">("dark"); const toastContentClassName = "min-w-0 p-0 pr-1"; const toastDescriptionClassName = @@ -69,6 +77,11 @@ export function AppProvider(props: { children: ReactNode }) { const syncSystemPreference = useEffectEvent((matches: boolean) => { setPrefersDark(matches); }); + const sidebarTranslucency = useSharedSettings((state) => state.sidebarTranslucency); + const [reducedTransparency, setReducedTransparency] = useState(systemPrefersReducedTransparency); + const syncReducedTransparency = useEffectEvent((matches: boolean) => { + setReducedTransparency(matches); + }); useEffect(() => { if (typeof window === "undefined" || typeof window.matchMedia !== "function") { @@ -87,7 +100,26 @@ export function AppProvider(props: { children: ReactNode }) { }; }, []); + useEffect(() => { + if (typeof window === "undefined" || typeof window.matchMedia !== "function") { + return; + } + + const media = window.matchMedia("(prefers-reduced-transparency: reduce)"); + const onChange = (event: MediaQueryListEvent) => { + syncReducedTransparency(event.matches); + }; + + syncReducedTransparency(media.matches); + media.addEventListener("change", onChange); + return () => { + media.removeEventListener("change", onChange); + }; + }, []); + const appearance = resolveThemeMode(themeMode, prefersDark); + // The opt-in translucent sidebar, suppressed when the OS asks for reduced transparency. + const glassEnabled = sidebarTranslucency && !reducedTransparency; useEffect(() => { const root = document.documentElement; @@ -98,6 +130,12 @@ export function AppProvider(props: { children: ReactNode }) { persistThemeBoot(appearance, themePreset); }, [appearance, themePreset]); + // Gates the in-app CSS sidebar tint/fallback. Set unconditionally (works in + // tests / non-Electron) so the styling never depends on the bridge. + useEffect(() => { + document.documentElement.dataset.sidebarGlass = glassEnabled ? "on" : "off"; + }, [glassEnabled]); + useEffect(() => { if (typeof window === "undefined" || !("lightcode" in window)) { return; @@ -111,12 +149,21 @@ export function AppProvider(props: { children: ReactNode }) { backgroundColor: styles.getPropertyValue("--window-overlay-background").trim() || "rgba(0, 0, 0, 0)", symbolColor: appearance === "dark" ? "#fafafa" : "#1f2937", + materialEnabled: glassEnabled, + appearance, + }) + .then((result) => { + // The main process reports whether a native blur material was actually + // applied (e.g. Windows 10 has none); gate the transparent-window CSS on + // that truthful state instead of guessing from the platform. + root.dataset.nativeMaterial = result?.nativeMaterial ? "on" : "off"; }) .catch((error: unknown) => { + root.dataset.nativeMaterial = "off"; captureRendererException(error, { featureArea: "window-chrome" }); // Keep renderer boot resilient if Electron rejects a color value. }); - }, [appearance]); + }, [appearance, glassEnabled]); return ( diff --git a/src/renderer/main.tsx b/src/renderer/main.tsx index 84315efe..da680393 100644 --- a/src/renderer/main.tsx +++ b/src/renderer/main.tsx @@ -37,6 +37,26 @@ initializeRendererSentry(); document.documentElement.dataset.platform = typeof window !== "undefined" && "lightcode" in window ? readBridge().platform : "unknown"; +// Pre-paint hint for the opt-in translucent ("liquid glass") sidebar so the +// window doesn't flash opaque before the renderer requests the material. The +// authoritative state is applied by provider.tsx once settings hydrate. +try { + const cached = localStorage.getItem("lightcode-shared-settings"); + const reducedTransparency = + typeof window.matchMedia === "function" && + window.matchMedia("(prefers-reduced-transparency: reduce)").matches; + const glassEnabled = + !reducedTransparency && cached != null && JSON.parse(cached)?.sidebarTranslucency === true; + document.documentElement.dataset.sidebarGlass = glassEnabled ? "on" : "off"; + // macOS always supports vibrancy, so the window already opened with it applied; + // Windows capability is build-dependent, so its hint waits for the bridge reply. + if (glassEnabled && "lightcode" in window && readBridge().platform === "darwin") { + document.documentElement.dataset.nativeMaterial = "on"; + } +} catch { + // Non-fatal: provider.tsx applies the authoritative state after hydration. +} + // Apply the cached appearance + theme before first paint so a non-default theme // doesn't flash the base palette on launch. bootstrapAppThemeFromCache(); diff --git a/src/renderer/state/sharedSettingsStore.ts b/src/renderer/state/sharedSettingsStore.ts index 10c61033..7c394edd 100644 --- a/src/renderer/state/sharedSettingsStore.ts +++ b/src/renderer/state/sharedSettingsStore.ts @@ -55,6 +55,7 @@ interface SharedSettingsState extends SharedSettings { setThreadRemoveAction: (value: ThreadRemoveAction) => void; setNewThreadMode: (value: NewThreadMode) => void; setHomeScopeEnabled: (value: boolean) => void; + setSidebarTranslucency: (value: boolean) => void; setAutoShowTerminalPanel: (value: boolean) => void; setGitReviewMode: (value: GitReviewMode) => void; setPrCreateMode: (value: PrCreateMode) => void; @@ -313,6 +314,11 @@ export const useSharedSettings = create()((set, get) => ({ set({ homeScopeEnabled }); persistSettings(selectSharedSettings(get())); }, + setSidebarTranslucency: (sidebarTranslucency) => { + if (get().sidebarTranslucency === sidebarTranslucency) return; + set({ sidebarTranslucency }); + persistSettings(selectSharedSettings(get())); + }, setAutoShowTerminalPanel: (autoShowTerminalPanel) => { set({ autoShowTerminalPanel }); persistSettings(selectSharedSettings(get())); @@ -568,6 +574,7 @@ function selectSharedSettings(state: SharedSettingsState): SharedSettingsInput { threadRemoveAction: state.threadRemoveAction, newThreadMode: state.newThreadMode, homeScopeEnabled: state.homeScopeEnabled, + sidebarTranslucency: state.sidebarTranslucency, autoShowTerminalPanel: state.autoShowTerminalPanel, gitReviewMode: state.gitReviewMode, prCreateMode: state.prCreateMode, diff --git a/src/renderer/styles.css b/src/renderer/styles.css index 2b10c1ee..b982243c 100644 --- a/src/renderer/styles.css +++ b/src/renderer/styles.css @@ -1008,6 +1008,83 @@ body { z-index: 20; } +/* === Translucent ("liquid glass") sidebar =================================== + Opt-in via the Appearance toggle (default off). Two paths: + + 1. Native material — [data-native-material="on"] is set by the renderer ONLY + after the main process confirms it actually applied a blur material + (macOS vibrancy / Windows 11 acrylic). The whole window is blurred by the + OS, so we clear the opaque window layers (body, shell) and re-cover the + main content with an opaque pane; only the sidebar (and the thin titlebar / + resize-handle gaps) stay translucent.
has no background of its own, + so it must be given one explicitly or the whole window goes translucent. + + 2. In-app fallback — [data-sidebar-glass="on"] without a native material + (Linux, Windows 10, older builds). The window stays opaque; the docked, + flush-left sidebar gets a faux-translucent gradient + bevel (a real + backdrop-filter would blur almost nothing there). Real blur is reserved + for the floating *overlay* sidebar, which genuinely sits over content. + + These rules are intentionally unlayered so they win over Tailwind utilities + (e.g. the sidebar's bg-[var(--content-background)] header class). */ + +/* 1. Native material: clear opaque window layers, keep main content opaque. */ +html[data-native-material="on"] body, +html[data-native-material="on"] .lightcode-shell { + background: transparent; +} +html[data-native-material="on"] .lightcode-shell main { + background: var(--content-background); +} +/* Faint tint so sidebar text/icons stay legible over the live OS blur. The + sidebar header (shared .lightcode-overlay-header class) is forced transparent + so the aside's single tint layer shows through instead of stacking. */ +html[data-native-material="on"] .lightcode-sidebar-aside { + background: color-mix(in oklab, var(--sidebar-background) 55%, transparent); +} +html[data-native-material="on"] .lightcode-sidebar-aside .lightcode-overlay-header { + background: transparent; +} + +/* 2. In-app fallback: opaque window, faux-translucent gradient sidebar. */ +html[data-sidebar-glass="on"]:not([data-native-material="on"]) .lightcode-sidebar-aside { + background: linear-gradient( + 180deg, + color-mix(in oklab, var(--sidebar-background) 90%, transparent) 0%, + color-mix(in oklab, var(--sidebar-background) 74%, transparent) 100% + ); + box-shadow: + inset 1px 0 0 color-mix(in oklab, var(--foreground) 7%, transparent), + inset 0 1px 0 color-mix(in oklab, var(--foreground) 9%, transparent); +} +html[data-sidebar-glass="on"]:not([data-native-material="on"]) + .lightcode-sidebar-aside + .lightcode-overlay-header { + background: transparent; +} +/* Real blur only for the floating overlay sidebar (it sits over content). */ +@supports (backdrop-filter: blur(1px)) { + html[data-sidebar-glass="on"]:not([data-native-material="on"]) .lightcode-sidebar-aside--overlay { + background: color-mix(in oklab, var(--sidebar-background) 70%, transparent); + backdrop-filter: blur(18px) saturate(1.5); + } +} + +/* Accessibility: drop translucency when the OS asks for reduced transparency. + The renderer also gates this in JS, so this is a first-paint safety net. */ +@media (prefers-reduced-transparency: reduce) { + html[data-native-material="on"] body, + html[data-native-material="on"] .lightcode-shell { + background: var(--content-background); + } + html[data-sidebar-glass="on"] .lightcode-sidebar-aside:not(.lightcode-sidebar-aside--overlay), + html[data-native-material="on"] .lightcode-sidebar-aside:not(.lightcode-sidebar-aside--overlay) { + background: transparent; + backdrop-filter: none; + box-shadow: none; + } +} + /* Counterpart to .lightcode-content-over-drag-region: used on pane headers when the pane can't be reordered (single-pane layouts), so the header acts as the window drag region instead of a dnd handle. macOS-only — diff --git a/src/renderer/views/MainView/parts/AppShell/AppShell.tsx b/src/renderer/views/MainView/parts/AppShell/AppShell.tsx index 02d5bcbe..e13a65d9 100644 --- a/src/renderer/views/MainView/parts/AppShell/AppShell.tsx +++ b/src/renderer/views/MainView/parts/AppShell/AppShell.tsx @@ -254,9 +254,9 @@ function ShellSidebarAside(props: { return (