Skip to content

Commit 8bc05ac

Browse files
OpenSource03claude
andauthored
feat: native liquid glass tinting and theme-controlled appearance (#39)
* feat: native liquid glass tinting, theme-controlled appearance, and focus veil Use electron-liquid-glass tintColor API to tint the glass material natively on macOS instead of CSS overlays. Switch to darkkatarsis fork which adds hasKeyAppearance isa-swizzle to prevent glass state change on window blur. - Add OKLCh-to-hex-RGBA color conversion utility (src/lib/color-utils.ts) - Add glass IPC: set-tint-color (native tint) and set-theme (nativeTheme.themeSource) - Sync app theme to main process so glass light/dark follows app setting, not OS - Bootstrap theme from localStorage in preload for correct first-frame appearance - Add subtle unfocused veil overlay on macOS (darken/brighten when window inactive) - Unify light mode glass sidebar to use dark text (same as Windows) - Fall back to CSS overlay tinting on non-macOS platforms Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com> * fix: harden glass tinting from PR review feedback - Guard clampByte against NaN/Infinity inputs (corrupted space data) - Validate tintColor hex format in IPC handler before passing to native - Fix unfocused veil transition: keep element mounted, toggle opacity - Align preload theme default with useSettings ("dark") to avoid flash - Add unit tests for oklchToHexRGBA and computeGlassTintColor Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com> --------- Co-authored-by: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
1 parent 3ec4248 commit 8bc05ac

12 files changed

Lines changed: 308 additions & 46 deletions

File tree

electron/src/lib/glass.ts

Lines changed: 39 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -3,8 +3,14 @@ import os from "os";
33
import { log } from "./logger";
44
import { reportError } from "./error-utils";
55

6+
interface GlassOptions {
7+
tintColor?: string;
8+
cornerRadius?: number;
9+
opaque?: boolean;
10+
}
11+
612
interface LiquidGlass {
7-
addView: (handle: Buffer, opts?: object) => number;
13+
addView: (handle: Buffer, opts?: GlassOptions) => number;
814
}
915

1016
let liquidGlass: LiquidGlass | null = null;
@@ -18,22 +24,32 @@ if (process.platform === "darwin") {
1824
// mainEntry = .../electron-liquid-glass/dist/index.cjs → go up past "dist/"
1925
const pkgDir = path.dirname(path.dirname(mainEntry));
2026

21-
// Load the .node prebuild directly, bypassing node-gyp-build which
22-
// isn't available in ASAR builds (pnpm doesn't hoist transitive deps)
27+
// Load the .node addon directly — try prebuild first, fall back to
28+
// electron-rebuild output (build/Release/).
29+
// Prebuilds aren't available for GitHub forks that lack CI-built binaries.
2330
const prebuildFile =
2431
process.arch === "arm64" ? "node.napi.armv8.node" : "node.napi.node";
2532
const prebuildPath = path.join(
2633
pkgDir, "prebuilds", `darwin-${process.arch}`, prebuildFile
2734
);
35+
const buildReleasePath = path.join(pkgDir, "build", "Release", "liquidglass.node");
36+
37+
let addonPath: string;
38+
try {
39+
require("fs").accessSync(prebuildPath);
40+
addonPath = prebuildPath;
41+
} catch {
42+
addonPath = buildReleasePath;
43+
}
2844

2945
// eslint-disable-next-line @typescript-eslint/no-require-imports
30-
const native = require(prebuildPath);
46+
const native = require(addonPath);
3147

3248
// Instantiate the native class directly (same as the library's JS wrapper does internally)
3349
const addon = new native.LiquidGlassNative();
3450
if (addon && typeof addon.addView === "function") {
3551
liquidGlass = addon;
36-
log("GLASS", `Native addon loaded from ${prebuildPath}`);
52+
log("GLASS", `Native addon loaded from ${addonPath}`);
3753
} else {
3854
log("GLASS", "Native addon loaded but addView not found");
3955
}
@@ -51,4 +67,21 @@ function isMacOSSequoiaOrLater(): boolean {
5167
}
5268

5369
export const glassEnabled = !!(liquidGlass && isMacOSSequoiaOrLater());
54-
export { liquidGlass };
70+
71+
// ── Dynamic tint support ──
72+
// Store the window handle so we can re-call addView() with updated tintColor.
73+
// The C++ AddGlassEffectView auto-removes previous glass views before creating
74+
// new ones in a single dispatch_sync block, so there is no visual gap.
75+
76+
let storedHandle: Buffer | null = null;
77+
78+
export function applyGlass(handle: Buffer, opts?: GlassOptions): number {
79+
if (!liquidGlass) return -1;
80+
storedHandle = handle;
81+
return liquidGlass.addView(handle, opts ?? {});
82+
}
83+
84+
export function setGlassTint(tintColor: string | null): number {
85+
if (!liquidGlass || !storedHandle) return -1;
86+
return liquidGlass.addView(storedHandle, tintColor ? { tintColor } : {});
87+
}

electron/src/main.ts

Lines changed: 27 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,5 @@
11
import { execSync } from "child_process";
2-
import { app, BrowserWindow, clipboard, globalShortcut, ipcMain, Menu, session, shell, systemPreferences } from "electron";
2+
import { app, BrowserWindow, clipboard, globalShortcut, ipcMain, Menu, nativeTheme, session, shell, systemPreferences } from "electron";
33
import path from "path";
44
import http from "http";
55
import contextMenu from "electron-context-menu";
@@ -22,7 +22,7 @@ if (process.platform !== "win32") {
2222
import { log } from "./lib/logger";
2323
import { reportError } from "./lib/error-utils";
2424
import { migrateFromOpenAcpUi } from "./lib/migration";
25-
import { glassEnabled, liquidGlass } from "./lib/glass";
25+
import { glassEnabled, applyGlass, setGlassTint } from "./lib/glass";
2626
import { initAutoUpdater, getIsInstallingUpdate } from "./lib/updater";
2727
import { initPostHog, shutdownPostHog, reinitPostHog, captureEvent } from "./lib/posthog";
2828
import { sessions } from "./ipc/claude-sessions";
@@ -141,13 +141,14 @@ function createWindow(): void {
141141
if (glassEnabled) {
142142
// macOS: apply liquid glass after content loads
143143
mainWindow.webContents.once("did-finish-load", () => {
144-
const glassId = liquidGlass!.addView(mainWindow!.getNativeWindowHandle(), {});
144+
const glassId = applyGlass(mainWindow!.getNativeWindowHandle());
145145
if (glassId === -1) {
146146
log("GLASS", "addView returned -1 — native addon failed, glass will not be visible");
147147
} else {
148148
log("GLASS", `Liquid glass applied, viewId=${glassId}`);
149149
}
150150
});
151+
151152
}
152153
}
153154

@@ -181,6 +182,29 @@ ipcMain.on("app:set-min-width", (_event, minWidth: number) => {
181182
}
182183
});
183184

185+
// Native glass tint — re-creates glass view with updated tintColor.
186+
// The C++ addon auto-cleans previous views in a single dispatch_sync block.
187+
const GLASS_TINT_RE = /^#[0-9a-fA-F]{8}$/;
188+
ipcMain.on("glass:set-tint-color", (_event, tintColor: string | null) => {
189+
if (!glassEnabled) return;
190+
if (tintColor !== null && (typeof tintColor !== "string" || !GLASS_TINT_RE.test(tintColor))) {
191+
log("GLASS", `Ignoring invalid tintColor: ${String(tintColor)}`);
192+
return;
193+
}
194+
const viewId = setGlassTint(tintColor);
195+
if (viewId >= 0) {
196+
log("GLASS", `setTintColor=${tintColor}, viewId=${viewId}`);
197+
}
198+
});
199+
200+
// Glass appearance — force light/dark/system on the native layer so the
201+
// glass effect follows the app's theme setting, not just the OS preference.
202+
ipcMain.on("glass:set-theme", (_event, theme: string) => {
203+
if (theme === "light" || theme === "dark" || theme === "system") {
204+
nativeTheme.themeSource = theme;
205+
}
206+
});
207+
184208
// --- Register all IPC modules ---
185209
spacesIpc.register();
186210
projectsIpc.register(getMainWindow);

electron/src/preload.ts

Lines changed: 16 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -36,13 +36,29 @@ try {
3636
root.classList.add("glass-enabled");
3737
}
3838
});
39+
40+
// Push stored theme to main process early so glass appearance is correct
41+
// before React mounts. Default to "dark" to match useSettings, which falls
42+
// back to "dark" when harnss-theme is unset — avoids a system→dark flash.
43+
const storedTheme = globals.localStorage?.getItem("harnss-theme");
44+
if (storedTheme === "light" || storedTheme === "dark" || storedTheme === "system") {
45+
ipcRenderer.send("glass:set-theme", storedTheme);
46+
} else {
47+
ipcRenderer.send("glass:set-theme", "dark");
48+
}
3949
} catch (e) {
4050
console.error("[preload] early setup failed:", e);
4151
}
4252

4353
contextBridge.exposeInMainWorld("claude", {
4454
getGlassSupported: () => ipcRenderer.invoke("app:getGlassSupported"),
4555
setMinWidth: (width: number) => ipcRenderer.send("app:set-min-width", width),
56+
glass: {
57+
setTintColor: (tintColor: string | null) =>
58+
ipcRenderer.send("glass:set-tint-color", tintColor),
59+
setTheme: (theme: string) =>
60+
ipcRenderer.send("glass:set-theme", theme),
61+
},
4662
start: (options: unknown) => ipcRenderer.invoke("claude:start", options),
4763
send: (sessionId: string, message: unknown) => ipcRenderer.invoke("claude:send", { sessionId, message }),
4864
stop: (sessionId: string, reason?: string) =>

package.json

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -38,7 +38,7 @@
3838
"@posthog/react": "^1.8.2",
3939
"@tanstack/react-virtual": "^3.13.22",
4040
"electron-context-menu": "^4.1.1",
41-
"electron-liquid-glass": "^1.1.1",
41+
"electron-liquid-glass": "github:darkkatarsis/electron-liquid-glass",
4242
"electron-updater": "^6.8.3",
4343
"konva": "^10.2.0",
4444
"mermaid": "^11.13.0",

pnpm-lock.yaml

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

src/components/AppLayout.tsx

Lines changed: 22 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -85,6 +85,21 @@ export function AppLayout() {
8585
);
8686
const isGlassActive = glassSupported && settings.transparency;
8787
const isLightGlass = isGlassActive && resolvedTheme !== "dark";
88+
const isNativeGlass = isGlassActive && isMac;
89+
90+
// ── Window focus tracking (subtle veil on macOS liquid glass when unfocused) ──
91+
const [windowFocused, setWindowFocused] = useState(true);
92+
useEffect(() => {
93+
if (!isNativeGlass) return;
94+
const onFocus = () => setWindowFocused(true);
95+
const onBlur = () => setWindowFocused(false);
96+
window.addEventListener("focus", onFocus);
97+
window.addEventListener("blur", onBlur);
98+
return () => {
99+
window.removeEventListener("focus", onFocus);
100+
window.removeEventListener("blur", onBlur);
101+
};
102+
}, [isNativeGlass]);
88103

89104
// ── Welcome wizard (first-run onboarding) ──
90105

@@ -380,6 +395,13 @@ Link: ${issue.url}`;
380395
style={glassOverlayStyle}
381396
/>
382397
)}
398+
{/* Unfocused veil — subtle dim/brighten on macOS liquid glass when window loses focus */}
399+
{isNativeGlass && (
400+
<div
401+
className={`pointer-events-none fixed inset-0 z-0 transition-opacity duration-300 ${windowFocused ? "opacity-0" : "opacity-100"}`}
402+
style={{ background: isLightGlass ? "rgba(255,255,255,0.38)" : "rgba(0,0,0,0.34)" }}
403+
/>
404+
)}
383405
<SpaceCreator
384406
open={spaceCreatorOpen}
385407
onOpenChange={setSpaceCreatorOpen}

src/hooks/useSpaceTheme.ts

Lines changed: 21 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,7 @@
11
import { useEffect, useState } from "react";
22
import type { Space } from "@/types";
3+
import { computeGlassTintColor } from "@/lib/color-utils";
4+
import { isMac } from "@/lib/utils";
35

46
const TINT_VARS = [
57
"--space-hue", "--space-chroma",
@@ -22,6 +24,10 @@ function getTintStrength(chroma: number): number {
2224
* Applies the active space's color tint to CSS custom properties on the document root.
2325
* Handles dark/light mode branching and glass/non-glass transparency.
2426
*
27+
* On macOS with native glass, sends tintColor to the main process via IPC so
28+
* the glass material is tinted natively (higher quality than CSS overlay).
29+
* Falls back to CSS overlay on non-macOS platforms (Windows Mica, etc.).
30+
*
2531
* Returns the glass overlay style object (or null) for the tint overlay div.
2632
*/
2733
export function useSpaceTheme(
@@ -36,11 +42,15 @@ export function useSpaceTheme(
3642
const root = document.documentElement;
3743
const isGlass = isGlassActive;
3844
const isDark = resolvedTheme === "dark";
45+
// Native macOS glass supports tintColor via addView()
46+
const isNativeGlass = isGlass && isMac;
3947

4048
if (!space || space.color.chroma === 0) {
4149
// Clear all tinted vars so the CSS base values take over
4250
for (const v of TINT_VARS) root.style.removeProperty(v);
4351
setGlassOverlayStyle(null);
52+
// Clear native glass tint when space has no color
53+
if (isNativeGlass) window.claude.glass?.setTintColor(null);
4454

4555
// Still apply opacity even for colorless (default) space
4656
const opacity = space?.color.opacity;
@@ -116,7 +126,16 @@ export function useSpaceTheme(
116126
const gradientHue = space.color.gradientHue;
117127
const overlayChroma = Math.min(0.18, 0.04 + 0.12 * tintStrength);
118128

119-
if (isGlass) {
129+
// ── Glass tinting ──
130+
if (isNativeGlass) {
131+
// Native macOS glass tinting via addView({ tintColor }).
132+
// The main process also re-applies tint on window focus (macOS drops it when inactive).
133+
const hexTint = computeGlassTintColor(space.color);
134+
window.claude.glass?.setTintColor(hexTint);
135+
// Native tint handles the glass material — no CSS overlay needed
136+
setGlassOverlayStyle(null);
137+
} else if (isGlass) {
138+
// Non-macOS glass (Windows Mica, etc.) — CSS overlay for tinting
120139
const a = 0.04 + 0.12 * tintStrength;
121140
const bg = gradientHue !== undefined
122141
? `linear-gradient(135deg, oklch(0.5 ${overlayChroma} ${hue} / ${a}), oklch(0.5 ${overlayChroma} ${gradientHue} / ${a}))`
@@ -140,6 +159,7 @@ export function useSpaceTheme(
140159
return () => {
141160
for (const v of TINT_VARS) root.style.removeProperty(v);
142161
setGlassOverlayStyle(null);
162+
if (isNativeGlass) window.claude.glass?.setTintColor(null);
143163
};
144164
}, [activeSpace, resolvedTheme, isGlassActive]);
145165

src/hooks/useTheme.ts

Lines changed: 7 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -44,5 +44,12 @@ export function useTheme(theme: ThemeOption): ResolvedTheme {
4444
}
4545
}, [resolved]);
4646

47+
// Sync theme to main process so native glass appearance matches the app theme.
48+
// Send the raw option ("light"/"dark"/"system"), not the resolved value,
49+
// so nativeTheme.themeSource = "system" lets macOS drive glass appearance natively.
50+
useEffect(() => {
51+
window.claude.glass?.setTheme(theme);
52+
}, [theme]);
53+
4754
return resolved;
4855
}

src/index.css

Lines changed: 2 additions & 30 deletions
Original file line numberDiff line numberDiff line change
@@ -157,36 +157,8 @@ html.glass-enabled #root {
157157
--sidebar: oklch(1 0 0 / 0.29);
158158
--sidebar-border: oklch(0 0 0 / 0.08);
159159
--sidebar-accent: oklch(0.965 0.001 286.029 / 0.3);
160-
/* White text on glass sidebar — native glass shows through dark, so sidebar
161-
text should be light (like dark mode) regardless of the app theme.
162-
Exception: search input keeps dark text (see below). */
163-
--sidebar-foreground: oklch(0.985 0 0);
164-
--sidebar-primary: oklch(0.488 0.243 264.376);
165-
--sidebar-primary-foreground: oklch(0.985 0 0);
166-
--sidebar-ring: oklch(0.64 0 0);
167-
/* Keep --sidebar-accent-foreground at light-mode default for other accent
168-
surfaces; selected chats are overridden below. */
169-
}
170-
171-
/* Elements on light backgrounds (search box) — reset to dark text/icons */
172-
.glass-enabled:not(.dark) .glass-outline,
173-
.glass-enabled:not(.dark) .bg-sidebar-accent {
174-
--sidebar-foreground: oklch(0.145 0 0);
175-
}
176-
177-
/* Sidebar search stays on the white-text treatment in light glass mode. */
178-
.glass-enabled:not(.dark) .sidebar-search-glass {
179-
--sidebar-foreground: oklch(0.985 0 0);
180-
}
181-
182-
/* In light mode with glass enabled, selected chats should stay white. */
183-
.glass-enabled:not(.dark) .session-item-active {
184-
color: oklch(0.985 0 0);
185-
}
186-
187-
/* In light glass mode, sidebar engine icons match white text via inversion */
188-
.glass-enabled:not(.dark) .session-item-button img {
189-
filter: invert(1) brightness(2);
160+
/* Light mode glass: keep dark text (same as Windows) — the always-key-appearance
161+
fix keeps the glass bright enough for standard dark-on-light text. */
190162
}
191163

192164
/* In glass mode, only the outermost bg-sidebar (AppLayout root) provides

0 commit comments

Comments
 (0)