Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 2 additions & 0 deletions src/main/sharedSettingsFile.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -76,6 +76,7 @@ describe("sharedSettingsFile", () => {
newThreadMode: "page",
homeScopeEnabled: true,
sidebarTranslucency: false,
sidebarGlassTint: { light: null, dark: null },
autoShowTerminalPanel: true,
gitReviewMode: "panel",
prCreateMode: "dialog",
Expand Down Expand Up @@ -164,6 +165,7 @@ describe("sharedSettingsFile", () => {
newThreadMode: "page",
homeScopeEnabled: true,
sidebarTranslucency: false,
sidebarGlassTint: { light: null, dark: null },
autoShowTerminalPanel: true,
gitReviewMode: "panel",
prCreateMode: "dialog",
Expand Down
5 changes: 4 additions & 1 deletion src/renderer/components/ui/provider.test.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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),
Expand Down
13 changes: 13 additions & 0 deletions src/renderer/components/ui/provider.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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";
Expand Down Expand Up @@ -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);
Expand Down Expand Up @@ -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 (
<AppearanceContext.Provider value={appearance}>
<Toast.Provider placement="bottom end" maxVisibleToasts={5}>
Expand Down
40 changes: 40 additions & 0 deletions src/renderer/hooks/useGlassState.ts
Original file line number Diff line number Diff line change
@@ -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");
}
8 changes: 8 additions & 0 deletions src/renderer/state/sharedSettingsStore.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand Down Expand Up @@ -319,6 +320,12 @@ export const useSharedSettings = create<SharedSettingsState>()((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()));
Expand Down Expand Up @@ -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,
Expand Down
15 changes: 15 additions & 0 deletions src/renderer/styles.css
Original file line number Diff line number Diff line change
Expand Up @@ -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 {
Expand Down
52 changes: 52 additions & 0 deletions src/renderer/theme/sidebarGlass.ts
Original file line number Diff line number Diff line change
@@ -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<Appearance, number> = {
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);
}
}
10 changes: 9 additions & 1 deletion src/renderer/views/MainView/parts/AppShell/AppShell.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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 {
Expand Down Expand Up @@ -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
Expand Down
89 changes: 85 additions & 4 deletions src/renderer/views/SettingsOverlay/parts/AppearanceSettings.tsx
Original file line number Diff line number Diff line change
@@ -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";
Expand All @@ -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 (
<SettingsPage title="Appearance">
Expand Down Expand Up @@ -109,6 +145,51 @@ export function AppearanceSettings() {
</Switch.Control>
</Switch>
</SettingRow>

{showGlassTintSlider ? (
<SettingRow
title="Sidebar frosting"
description={`Frosting of the ${appearance}-mode sidebar over the system blur. Higher holds the theme color; lower shows more of what's behind.`}
>
<Slider
aria-label="Sidebar frosting"
className="w-[220px] shrink-0"
minValue={0}
maxValue={100}
step={5}
value={glassTint}
onChange={previewGlassTint}
onChangeEnd={(next) => {
startTransition(() => {
setSidebarGlassTint(appearance, normalizeSliderValue(next));
});
}}
>
<div className="mb-1.5 flex items-center justify-end gap-1">
<SliderOutput className="text-xs tabular-nums text-muted">
{(values) => `${values.state.getThumbValueLabel(0)}%`}
</SliderOutput>
{/* Always rendered so the value never shifts; hidden (space reserved)
until there's an override to clear. */}
<button
type="button"
aria-label="Reset sidebar frosting to default"
title="Reset to default"
onClick={resetGlassTint}
className={`inline-flex items-center justify-center rounded p-0.5 text-muted transition-colors hover:text-foreground ${
glassTintOverride == null ? "invisible" : ""
}`}
>
<RotateCcw className="size-3.5" />
</button>
</div>
<SliderTrack>
<SliderFill />
<SliderThumb />
</SliderTrack>
</Slider>
</SettingRow>
) : null}
</SettingsPage>
);
}
12 changes: 12 additions & 0 deletions src/shared/settings.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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. */
Expand Down Expand Up @@ -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",
Expand Down