diff --git a/src/main/sharedSettingsFile.test.ts b/src/main/sharedSettingsFile.test.ts index 89836320..ad6567c2 100644 --- a/src/main/sharedSettingsFile.test.ts +++ b/src/main/sharedSettingsFile.test.ts @@ -76,6 +76,7 @@ describe("sharedSettingsFile", () => { newThreadMode: "page", homeScopeEnabled: true, sidebarTranslucency: false, + sidebarGlassTint: { light: null, dark: null }, autoShowTerminalPanel: true, gitReviewMode: "panel", prCreateMode: "dialog", @@ -164,6 +165,7 @@ describe("sharedSettingsFile", () => { newThreadMode: "page", homeScopeEnabled: true, sidebarTranslucency: false, + sidebarGlassTint: { light: null, dark: null }, autoShowTerminalPanel: true, gitReviewMode: "panel", prCreateMode: "dialog", diff --git a/src/renderer/components/ui/provider.test.tsx b/src/renderer/components/ui/provider.test.tsx index 0db65115..34cf09cb 100644 --- a/src/renderer/components/ui/provider.test.tsx +++ b/src/renderer/components/ui/provider.test.tsx @@ -2,7 +2,10 @@ import { render, screen } from "@testing-library/react"; import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; import type { ThemeMode } from "@/shared/contracts"; -const settingsState: { themeMode: ThemeMode } = { themeMode: "system" }; +const settingsState: { + themeMode: ThemeMode; + sidebarGlassTint: { light: number | null; dark: number | null }; +} = { themeMode: "system", sidebarGlassTint: { light: null, dark: null } }; vi.mock("../../state/sharedSettingsStore", () => ({ useSharedSettings: (selector: (s: typeof settingsState) => unknown) => selector(settingsState), diff --git a/src/renderer/components/ui/provider.tsx b/src/renderer/components/ui/provider.tsx index 6a03be2e..ddee1a56 100644 --- a/src/renderer/components/ui/provider.tsx +++ b/src/renderer/components/ui/provider.tsx @@ -10,6 +10,7 @@ import { Toast, toast as heroToast } from "@heroui/react"; import { Copy } from "lucide-react"; import { resolveThemeMode } from "@/shared/themeMode"; import { applyAppTheme, persistThemeBoot, systemPrefersDark } from "@/renderer/theme/applyAppTheme"; +import { applySidebarGlassTint } from "@/renderer/theme/sidebarGlass"; import { readBridge } from "@/renderer/bridge"; import { captureRendererException } from "@/renderer/diagnostics/sentry"; import { useSharedSettings } from "@/renderer/state/sharedSettingsStore"; @@ -81,6 +82,7 @@ export function AppProvider(props: { children: ReactNode; contentReady?: boolean setPrefersDark(matches); }); const sidebarTranslucency = useSharedSettings((state) => state.sidebarTranslucency); + const sidebarGlassTint = useSharedSettings((state) => state.sidebarGlassTint); const [reducedTransparency, setReducedTransparency] = useState(systemPrefersReducedTransparency); const syncReducedTransparency = useEffectEvent((matches: boolean) => { setReducedTransparency(matches); @@ -168,6 +170,17 @@ export function AppProvider(props: { children: ReactNode; contentReady?: boolean }); }, [appearance, glassEnabled, contentReady]); + // User-tuned sidebar frosting (Appearance slider): override the glass tint + // alpha for the active appearance. No-op off Windows / when an appearance has + // no override, leaving the styles.css per-platform default authoritative. + useEffect(() => { + applySidebarGlassTint( + document.documentElement, + sidebarGlassTint[appearance], + glassEnabled && contentReady, + ); + }, [appearance, glassEnabled, contentReady, sidebarGlassTint]); + return ( diff --git a/src/renderer/hooks/useGlassState.ts b/src/renderer/hooks/useGlassState.ts new file mode 100644 index 00000000..6cc83eb2 --- /dev/null +++ b/src/renderer/hooks/useGlassState.ts @@ -0,0 +1,40 @@ +import { useEffect, useState } from "react"; + +/** + * Reactively reads a boolean dataset flag on the document root (e.g. + * `data-sidebar-glass="on"`). The AppProvider toggles these attributes + * asynchronously (after confirming OS support / once content is ready), so we + * observe the attribute rather than read it once at mount. + */ +function useHtmlFlag(attribute: string, onValue = "on"): boolean { + const [active, setActive] = useState( + () => + typeof document !== "undefined" && + document.documentElement.getAttribute(attribute) === onValue, + ); + useEffect(() => { + const root = document.documentElement; + const update = () => setActive(root.getAttribute(attribute) === onValue); + update(); + const observer = new MutationObserver(update); + observer.observe(root, { attributes: true, attributeFilter: [attribute] }); + return () => observer.disconnect(); + }, [attribute, onValue]); + return active; +} + +/** + * Whether the translucent ("liquid glass") sidebar is currently rendered — + * either the native blur material or the in-app fallback tint. + */ +export function useSidebarGlassActive(): boolean { + return useHtmlFlag("data-sidebar-glass"); +} + +/** + * Whether a native OS blur material (Windows 11 acrylic / macOS vibrancy) is + * composited behind the window. + */ +export function useNativeMaterialActive(): boolean { + return useHtmlFlag("data-native-material"); +} diff --git a/src/renderer/state/sharedSettingsStore.ts b/src/renderer/state/sharedSettingsStore.ts index 7c394edd..ecfbfd67 100644 --- a/src/renderer/state/sharedSettingsStore.ts +++ b/src/renderer/state/sharedSettingsStore.ts @@ -56,6 +56,7 @@ interface SharedSettingsState extends SharedSettings { setNewThreadMode: (value: NewThreadMode) => void; setHomeScopeEnabled: (value: boolean) => void; setSidebarTranslucency: (value: boolean) => void; + setSidebarGlassTint: (appearance: "light" | "dark", value: number | null) => void; setAutoShowTerminalPanel: (value: boolean) => void; setGitReviewMode: (value: GitReviewMode) => void; setPrCreateMode: (value: PrCreateMode) => void; @@ -319,6 +320,12 @@ export const useSharedSettings = create()((set, get) => ({ set({ sidebarTranslucency }); persistSettings(selectSharedSettings(get())); }, + setSidebarGlassTint: (appearance, value) => { + const current = get().sidebarGlassTint; + if (current[appearance] === value) return; + set({ sidebarGlassTint: { ...current, [appearance]: value } }); + persistSettings(selectSharedSettings(get())); + }, setAutoShowTerminalPanel: (autoShowTerminalPanel) => { set({ autoShowTerminalPanel }); persistSettings(selectSharedSettings(get())); @@ -575,6 +582,7 @@ function selectSharedSettings(state: SharedSettingsState): SharedSettingsInput { newThreadMode: state.newThreadMode, homeScopeEnabled: state.homeScopeEnabled, sidebarTranslucency: state.sidebarTranslucency, + sidebarGlassTint: state.sidebarGlassTint, autoShowTerminalPanel: state.autoShowTerminalPanel, gitReviewMode: state.gitReviewMode, prCreateMode: state.prCreateMode, diff --git a/src/renderer/styles.css b/src/renderer/styles.css index 823c7cfd..ee9714b2 100644 --- a/src/renderer/styles.css +++ b/src/renderer/styles.css @@ -1059,6 +1059,21 @@ html[data-native-material="on"] .lightcode-sidebar-aside { html[data-native-material="on"] .lightcode-sidebar-aside .lightcode-overlay-header { background: transparent; } +/* Windows-only frosting bump. DWM acrylic blurs whatever sits behind the window, + so the backdrop bleeds through the tint and drags the sidebar toward it: a dark + theme over a bright window washes out toward grey, while a light theme over a + dark backdrop goes too dark. Lean the tint more opaque on Windows so the sidebar + holds its own theme color regardless of what's behind. Dark leans heavier (85%) + since a dark surface over a bright backdrop swings the most; light needs less + (65%). macOS vibrancy composites its own adaptive material, so darwin is left + untouched; these rules are unlayered so they win over the @layer base token. */ +html[data-platform="win32"][data-native-material="on"] { + --sidebar-glass-tint: color-mix(in oklab, var(--content-background) 65%, transparent); +} +html[data-platform="win32"][data-native-material="on"].dark, +html[data-platform="win32"][data-native-material="on"][data-theme="dark"] { + --sidebar-glass-tint: color-mix(in oklab, var(--content-background) 85%, transparent); +} /* 2. In-app fallback: opaque window, faux-translucent gradient sidebar. */ html[data-sidebar-glass="on"]:not([data-native-material="on"]) .lightcode-sidebar-aside { diff --git a/src/renderer/theme/sidebarGlass.ts b/src/renderer/theme/sidebarGlass.ts new file mode 100644 index 00000000..eb45c02b --- /dev/null +++ b/src/renderer/theme/sidebarGlass.ts @@ -0,0 +1,52 @@ +import { isWindows } from "@/renderer/bridge"; + +/** + * User-tunable frosting for the translucent ("liquid glass") sidebar. + * + * The sidebar paints `var(--sidebar-glass-tint)` over the OS blur material; the + * tint is `content-background` at a partial alpha. A higher alpha is more + * frosted (the sidebar holds its theme color), a lower one shows more of the + * blurred backdrop. The Appearance slider overrides that alpha per light/dark. + * + * Windows-only at apply time: DWM acrylic blurs whatever sits behind the window, + * so the backdrop can wash the sidebar out and the override matters most there. + * macOS vibrancy composites its own adaptive material and keeps the styles.css + * default. An unset (null) override leaves the per-platform default in + * styles.css authoritative. + */ + +type Appearance = "light" | "dark"; + +const CSS_VAR = "--sidebar-glass-tint"; + +/** + * Default mix percentage per appearance on Windows. Mirrors the + * `html[data-platform="win32"]` `--sidebar-glass-tint` rules in styles.css — + * keep the two in sync. Used to seed the slider when there is no override. + */ +export const WINDOWS_GLASS_TINT_DEFAULT: Record = { + light: 65, + dark: 85, +}; + +/** The `color-mix()` expression for a frosting percentage (0–100). */ +export function sidebarGlassTintExpr(pct: number): string { + return `color-mix(in oklab, var(--content-background) ${pct}%, transparent)`; +} + +/** + * Apply (or clear) the user's sidebar frosting override as an inline custom + * property on the document root. Inline wins over the styles.css defaults; + * clearing falls back to them. No-op off Windows so macOS vibrancy is untouched. + */ +export function applySidebarGlassTint( + root: HTMLElement, + override: number | null, + enabled: boolean, +): void { + if (enabled && isWindows() && override != null) { + root.style.setProperty(CSS_VAR, sidebarGlassTintExpr(override)); + } else { + root.style.removeProperty(CSS_VAR); + } +} diff --git a/src/renderer/views/MainView/parts/AppShell/AppShell.tsx b/src/renderer/views/MainView/parts/AppShell/AppShell.tsx index e13a65d9..5a853261 100644 --- a/src/renderer/views/MainView/parts/AppShell/AppShell.tsx +++ b/src/renderer/views/MainView/parts/AppShell/AppShell.tsx @@ -11,6 +11,7 @@ import { import { useShallow } from "zustand/shallow"; import { isMac, isWindows } from "@/renderer/bridge"; import { useTwoRafReady } from "@/renderer/hooks/useTwoRafReady"; +import { useSidebarGlassActive } from "@/renderer/hooks/useGlassState"; import { useSharedSettings } from "@/renderer/state/sharedSettingsStore"; import { macosTrafficLightPadClass } from "@/renderer/components/layout/sidebarChrome"; import { @@ -235,10 +236,17 @@ function ShellSidebarAside(props: { const effectiveClosingOverlay = forceSidebarExpanded ? false : closingOverlay; const effectiveIsOverlay = forceSidebarExpanded ? false : isOverlay; + // A translucent sidebar reads as its own glass edge, so the hard hairline + // between it and the content looks heavy. Drop it (transparent, keeping the + // 1px so width doesn't shift) while glass is active — but still flash the + // accent on resize-handle hover, since that border is the only resize cue. + const glassActive = useSidebarGlassActive(); const sidebarDividerColorClass = isSidebarHandleHovered && !effectiveIsOverlay ? "border-[color:var(--accent)]" - : "border-[color:var(--border)]"; + : glassActive + ? "border-transparent" + : "border-[color:var(--border)]"; // Windows: stop the sidebar divider below the header so it doesn't run through the title row. // macOS keeps the full-height border because the header sits inside the hidden-inset titlebar. // HOWEVER, if the sidebar is too narrow (e.g. collapsed), the full-height border would run diff --git a/src/renderer/views/SettingsOverlay/parts/AppearanceSettings.tsx b/src/renderer/views/SettingsOverlay/parts/AppearanceSettings.tsx index b57223d6..8e108120 100644 --- a/src/renderer/views/SettingsOverlay/parts/AppearanceSettings.tsx +++ b/src/renderer/views/SettingsOverlay/parts/AppearanceSettings.tsx @@ -1,11 +1,13 @@ -import { startTransition, useState, type CSSProperties } from "react"; -import { ChevronDown } from "lucide-react"; -import { Switch } from "@heroui/react"; +import { startTransition, useEffect, useState, type CSSProperties } from "react"; +import { ChevronDown, RotateCcw } from "lucide-react"; +import { Slider, SliderFill, SliderOutput, SliderThumb, SliderTrack, Switch } from "@heroui/react"; import type { ThemeMode } from "@/shared/contracts"; -import { isMac } from "@/renderer/bridge"; +import { isMac, isWindows } from "@/renderer/bridge"; import { useSharedSettings } from "@/renderer/state/sharedSettingsStore"; import { useResolvedAppearance } from "@/renderer/components/ui/provider"; import { getThemePreset } from "@/renderer/theme/themePresets"; +import { WINDOWS_GLASS_TINT_DEFAULT, applySidebarGlassTint } from "@/renderer/theme/sidebarGlass"; +import { useNativeMaterialActive } from "@/renderer/hooks/useGlassState"; import { Select } from "@/renderer/components/common"; import { SettingRow, SettingsPage } from "./SettingsForm"; import { ThemeGallery, ThemeSwatch } from "./ThemeGallery"; @@ -25,6 +27,40 @@ export function AppearanceSettings() { const setGuiChatFontSize = useSharedSettings((state) => state.setGuiChatFontSize); const sidebarTranslucency = useSharedSettings((state) => state.sidebarTranslucency); const setSidebarTranslucency = useSharedSettings((state) => state.setSidebarTranslucency); + const sidebarGlassTint = useSharedSettings((state) => state.sidebarGlassTint); + const setSidebarGlassTint = useSharedSettings((state) => state.setSidebarGlassTint); + + // Frosting slider: tunes the active appearance's glass tint. Windows-only (the + // override only applies there); local state drives a live preview during drag + // and the store persists on release. Seeds from the override, else the default. + // Gated on the live acrylic material so it never shows as a no-op where the + // token isn't consumed (Windows 10 / older builds use the fallback gradient). + const nativeMaterialActive = useNativeMaterialActive(); + const showGlassTintSlider = isWindows() && nativeMaterialActive; + const glassTintOverride = sidebarGlassTint[appearance]; + const glassTintDefault = WINDOWS_GLASS_TINT_DEFAULT[appearance]; + const [glassTint, setGlassTint] = useState(glassTintOverride ?? glassTintDefault); + useEffect(() => { + setGlassTint(glassTintOverride ?? glassTintDefault); + }, [glassTintOverride, glassTintDefault]); + // HeroUI's Slider emits number | number[]; this control is single-thumb. + const normalizeSliderValue = (value: number | number[]): number => + Array.isArray(value) ? (value[0] ?? glassTint) : value; + const previewGlassTint = (next: number | number[]) => { + const pct = normalizeSliderValue(next); + setGlassTint(pct); + // Live preview through the same writer the provider uses, so there's one + // place that knows how to set/clear the inline tint. + applySidebarGlassTint(document.documentElement, pct, true); + }; + const resetGlassTint = () => { + setGlassTint(glassTintDefault); + // Clear the override so the styles.css per-platform default takes back over. + applySidebarGlassTint(document.documentElement, null, true); + startTransition(() => { + setSidebarGlassTint(appearance, null); + }); + }; return ( @@ -109,6 +145,51 @@ export function AppearanceSettings() { + + {showGlassTintSlider ? ( + + { + startTransition(() => { + setSidebarGlassTint(appearance, normalizeSliderValue(next)); + }); + }} + > +
+ + {(values) => `${values.state.getThumbValueLabel(0)}%`} + + {/* Always rendered so the value never shifts; hidden (space reserved) + until there's an override to clear. */} + +
+ + + + +
+
+ ) : null}
); } diff --git a/src/shared/settings.ts b/src/shared/settings.ts index b5f5aa98..f204d800 100644 --- a/src/shared/settings.ts +++ b/src/shared/settings.ts @@ -201,6 +201,17 @@ export const sharedSettingsSchema = z.object({ * and an in-app translucent fallback elsewhere. Default off. */ sidebarTranslucency: z.boolean(), + /** + * Per-appearance override for the translucent sidebar's frosting: the alpha + * (0–100) of the `--sidebar-glass-tint` content-background mix. Higher is more + * frosted (holds the theme color); lower shows more of the blurred backdrop. + * `null` keeps the built-in per-platform default (see styles.css). Applied + * Windows-only — macOS vibrancy keeps its own tint. + */ + sidebarGlassTint: z.object({ + light: z.number().int().min(0).max(100).nullable().default(null), + dark: z.number().int().min(0).max(100).nullable().default(null), + }), /** Automatically show the terminal panel when running commands or creating worktrees. */ autoShowTerminalPanel: z.boolean(), /** Open git review as a right-side panel or a full page overlay. */ @@ -339,6 +350,7 @@ export const defaultSharedSettings: SharedSettings = { newThreadMode: "page", homeScopeEnabled: true, sidebarTranslucency: false, + sidebarGlassTint: { light: null, dark: null }, autoShowTerminalPanel: true, gitReviewMode: "panel", prCreateMode: "dialog",