diff --git a/electron/src/lib/glass.ts b/electron/src/lib/glass.ts index c64efd0..544cb7b 100644 --- a/electron/src/lib/glass.ts +++ b/electron/src/lib/glass.ts @@ -3,8 +3,14 @@ import os from "os"; import { log } from "./logger"; import { reportError } from "./error-utils"; +interface GlassOptions { + tintColor?: string; + cornerRadius?: number; + opaque?: boolean; +} + interface LiquidGlass { - addView: (handle: Buffer, opts?: object) => number; + addView: (handle: Buffer, opts?: GlassOptions) => number; } let liquidGlass: LiquidGlass | null = null; @@ -18,22 +24,32 @@ if (process.platform === "darwin") { // mainEntry = .../electron-liquid-glass/dist/index.cjs → go up past "dist/" const pkgDir = path.dirname(path.dirname(mainEntry)); - // Load the .node prebuild directly, bypassing node-gyp-build which - // isn't available in ASAR builds (pnpm doesn't hoist transitive deps) + // Load the .node addon directly — try prebuild first, fall back to + // electron-rebuild output (build/Release/). + // Prebuilds aren't available for GitHub forks that lack CI-built binaries. const prebuildFile = process.arch === "arm64" ? "node.napi.armv8.node" : "node.napi.node"; const prebuildPath = path.join( pkgDir, "prebuilds", `darwin-${process.arch}`, prebuildFile ); + const buildReleasePath = path.join(pkgDir, "build", "Release", "liquidglass.node"); + + let addonPath: string; + try { + require("fs").accessSync(prebuildPath); + addonPath = prebuildPath; + } catch { + addonPath = buildReleasePath; + } // eslint-disable-next-line @typescript-eslint/no-require-imports - const native = require(prebuildPath); + const native = require(addonPath); // Instantiate the native class directly (same as the library's JS wrapper does internally) const addon = new native.LiquidGlassNative(); if (addon && typeof addon.addView === "function") { liquidGlass = addon; - log("GLASS", `Native addon loaded from ${prebuildPath}`); + log("GLASS", `Native addon loaded from ${addonPath}`); } else { log("GLASS", "Native addon loaded but addView not found"); } @@ -51,4 +67,21 @@ function isMacOSSequoiaOrLater(): boolean { } export const glassEnabled = !!(liquidGlass && isMacOSSequoiaOrLater()); -export { liquidGlass }; + +// ── Dynamic tint support ── +// Store the window handle so we can re-call addView() with updated tintColor. +// The C++ AddGlassEffectView auto-removes previous glass views before creating +// new ones in a single dispatch_sync block, so there is no visual gap. + +let storedHandle: Buffer | null = null; + +export function applyGlass(handle: Buffer, opts?: GlassOptions): number { + if (!liquidGlass) return -1; + storedHandle = handle; + return liquidGlass.addView(handle, opts ?? {}); +} + +export function setGlassTint(tintColor: string | null): number { + if (!liquidGlass || !storedHandle) return -1; + return liquidGlass.addView(storedHandle, tintColor ? { tintColor } : {}); +} diff --git a/electron/src/main.ts b/electron/src/main.ts index afaf703..4df309b 100644 --- a/electron/src/main.ts +++ b/electron/src/main.ts @@ -1,5 +1,5 @@ import { execSync } from "child_process"; -import { app, BrowserWindow, clipboard, globalShortcut, ipcMain, Menu, session, shell, systemPreferences } from "electron"; +import { app, BrowserWindow, clipboard, globalShortcut, ipcMain, Menu, nativeTheme, session, shell, systemPreferences } from "electron"; import path from "path"; import http from "http"; import contextMenu from "electron-context-menu"; @@ -22,7 +22,7 @@ if (process.platform !== "win32") { import { log } from "./lib/logger"; import { reportError } from "./lib/error-utils"; import { migrateFromOpenAcpUi } from "./lib/migration"; -import { glassEnabled, liquidGlass } from "./lib/glass"; +import { glassEnabled, applyGlass, setGlassTint } from "./lib/glass"; import { initAutoUpdater, getIsInstallingUpdate } from "./lib/updater"; import { initPostHog, shutdownPostHog, reinitPostHog, captureEvent } from "./lib/posthog"; import { sessions } from "./ipc/claude-sessions"; @@ -141,13 +141,14 @@ function createWindow(): void { if (glassEnabled) { // macOS: apply liquid glass after content loads mainWindow.webContents.once("did-finish-load", () => { - const glassId = liquidGlass!.addView(mainWindow!.getNativeWindowHandle(), {}); + const glassId = applyGlass(mainWindow!.getNativeWindowHandle()); if (glassId === -1) { log("GLASS", "addView returned -1 — native addon failed, glass will not be visible"); } else { log("GLASS", `Liquid glass applied, viewId=${glassId}`); } }); + } } @@ -181,6 +182,29 @@ ipcMain.on("app:set-min-width", (_event, minWidth: number) => { } }); +// Native glass tint — re-creates glass view with updated tintColor. +// The C++ addon auto-cleans previous views in a single dispatch_sync block. +const GLASS_TINT_RE = /^#[0-9a-fA-F]{8}$/; +ipcMain.on("glass:set-tint-color", (_event, tintColor: string | null) => { + if (!glassEnabled) return; + if (tintColor !== null && (typeof tintColor !== "string" || !GLASS_TINT_RE.test(tintColor))) { + log("GLASS", `Ignoring invalid tintColor: ${String(tintColor)}`); + return; + } + const viewId = setGlassTint(tintColor); + if (viewId >= 0) { + log("GLASS", `setTintColor=${tintColor}, viewId=${viewId}`); + } +}); + +// Glass appearance — force light/dark/system on the native layer so the +// glass effect follows the app's theme setting, not just the OS preference. +ipcMain.on("glass:set-theme", (_event, theme: string) => { + if (theme === "light" || theme === "dark" || theme === "system") { + nativeTheme.themeSource = theme; + } +}); + // --- Register all IPC modules --- spacesIpc.register(); projectsIpc.register(getMainWindow); diff --git a/electron/src/preload.ts b/electron/src/preload.ts index 09a50dd..9c12ae0 100644 --- a/electron/src/preload.ts +++ b/electron/src/preload.ts @@ -36,6 +36,16 @@ try { root.classList.add("glass-enabled"); } }); + + // Push stored theme to main process early so glass appearance is correct + // before React mounts. Default to "dark" to match useSettings, which falls + // back to "dark" when harnss-theme is unset — avoids a system→dark flash. + const storedTheme = globals.localStorage?.getItem("harnss-theme"); + if (storedTheme === "light" || storedTheme === "dark" || storedTheme === "system") { + ipcRenderer.send("glass:set-theme", storedTheme); + } else { + ipcRenderer.send("glass:set-theme", "dark"); + } } catch (e) { console.error("[preload] early setup failed:", e); } @@ -43,6 +53,12 @@ try { contextBridge.exposeInMainWorld("claude", { getGlassSupported: () => ipcRenderer.invoke("app:getGlassSupported"), setMinWidth: (width: number) => ipcRenderer.send("app:set-min-width", width), + glass: { + setTintColor: (tintColor: string | null) => + ipcRenderer.send("glass:set-tint-color", tintColor), + setTheme: (theme: string) => + ipcRenderer.send("glass:set-theme", theme), + }, start: (options: unknown) => ipcRenderer.invoke("claude:start", options), send: (sessionId: string, message: unknown) => ipcRenderer.invoke("claude:send", { sessionId, message }), stop: (sessionId: string, reason?: string) => diff --git a/package.json b/package.json index e1f39ee..745dd69 100644 --- a/package.json +++ b/package.json @@ -38,7 +38,7 @@ "@posthog/react": "^1.8.2", "@tanstack/react-virtual": "^3.13.22", "electron-context-menu": "^4.1.1", - "electron-liquid-glass": "^1.1.1", + "electron-liquid-glass": "github:darkkatarsis/electron-liquid-glass", "electron-updater": "^6.8.3", "konva": "^10.2.0", "mermaid": "^11.13.0", diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 80e1106..d55e3b9 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -33,8 +33,8 @@ importers: specifier: ^4.1.1 version: 4.1.1 electron-liquid-glass: - specifier: ^1.1.1 - version: 1.1.1 + specifier: github:darkkatarsis/electron-liquid-glass + version: https://codeload.github.com/darkkatarsis/electron-liquid-glass/tar.gz/3e542a5c50db0b2d6c59734766eaf5805ec57efc electron-updater: specifier: ^6.8.3 version: 6.8.3 @@ -3079,8 +3079,9 @@ packages: resolution: {integrity: sha512-8TjjAh8Ec51hUi3o4TaU0mD3GMTOESi866oRNavj9A3IQJ7pmv+MJVmdZBFGw4GFT36X7bkqnuDNYvkQgvyI8Q==} engines: {node: '>=18'} - electron-liquid-glass@1.1.1: - resolution: {integrity: sha512-AfPxcu6RJYxlsW2sjgjDEON0rVOjhh5QYM6ur5hsfILQmfY5ewHCWJZJZoDsQxhjeW1DAhbRbvB79IvgW4ansA==} + electron-liquid-glass@https://codeload.github.com/darkkatarsis/electron-liquid-glass/tar.gz/3e542a5c50db0b2d6c59734766eaf5805ec57efc: + resolution: {tarball: https://codeload.github.com/darkkatarsis/electron-liquid-glass/tar.gz/3e542a5c50db0b2d6c59734766eaf5805ec57efc} + version: 1.1.1 engines: {node: '>=18'} os: [darwin] @@ -8703,7 +8704,7 @@ snapshots: electron-is-dev@3.0.1: {} - electron-liquid-glass@1.1.1: + electron-liquid-glass@https://codeload.github.com/darkkatarsis/electron-liquid-glass/tar.gz/3e542a5c50db0b2d6c59734766eaf5805ec57efc: dependencies: bindings: 1.5.0 node-addon-api: 8.5.0 diff --git a/src/components/AppLayout.tsx b/src/components/AppLayout.tsx index df72cab..a615035 100644 --- a/src/components/AppLayout.tsx +++ b/src/components/AppLayout.tsx @@ -85,6 +85,21 @@ export function AppLayout() { ); const isGlassActive = glassSupported && settings.transparency; const isLightGlass = isGlassActive && resolvedTheme !== "dark"; + const isNativeGlass = isGlassActive && isMac; + + // ── Window focus tracking (subtle veil on macOS liquid glass when unfocused) ── + const [windowFocused, setWindowFocused] = useState(true); + useEffect(() => { + if (!isNativeGlass) return; + const onFocus = () => setWindowFocused(true); + const onBlur = () => setWindowFocused(false); + window.addEventListener("focus", onFocus); + window.addEventListener("blur", onBlur); + return () => { + window.removeEventListener("focus", onFocus); + window.removeEventListener("blur", onBlur); + }; + }, [isNativeGlass]); // ── Welcome wizard (first-run onboarding) ── @@ -380,6 +395,13 @@ Link: ${issue.url}`; style={glassOverlayStyle} /> )} + {/* Unfocused veil — subtle dim/brighten on macOS liquid glass when window loses focus */} + {isNativeGlass && ( +
+ )} { for (const v of TINT_VARS) root.style.removeProperty(v); setGlassOverlayStyle(null); + if (isNativeGlass) window.claude.glass?.setTintColor(null); }; }, [activeSpace, resolvedTheme, isGlassActive]); diff --git a/src/hooks/useTheme.ts b/src/hooks/useTheme.ts index dd95694..4225721 100644 --- a/src/hooks/useTheme.ts +++ b/src/hooks/useTheme.ts @@ -44,5 +44,12 @@ export function useTheme(theme: ThemeOption): ResolvedTheme { } }, [resolved]); + // Sync theme to main process so native glass appearance matches the app theme. + // Send the raw option ("light"/"dark"/"system"), not the resolved value, + // so nativeTheme.themeSource = "system" lets macOS drive glass appearance natively. + useEffect(() => { + window.claude.glass?.setTheme(theme); + }, [theme]); + return resolved; } diff --git a/src/index.css b/src/index.css index ee4958a..94d1846 100644 --- a/src/index.css +++ b/src/index.css @@ -157,36 +157,8 @@ html.glass-enabled #root { --sidebar: oklch(1 0 0 / 0.29); --sidebar-border: oklch(0 0 0 / 0.08); --sidebar-accent: oklch(0.965 0.001 286.029 / 0.3); - /* White text on glass sidebar — native glass shows through dark, so sidebar - text should be light (like dark mode) regardless of the app theme. - Exception: search input keeps dark text (see below). */ - --sidebar-foreground: oklch(0.985 0 0); - --sidebar-primary: oklch(0.488 0.243 264.376); - --sidebar-primary-foreground: oklch(0.985 0 0); - --sidebar-ring: oklch(0.64 0 0); - /* Keep --sidebar-accent-foreground at light-mode default for other accent - surfaces; selected chats are overridden below. */ -} - -/* Elements on light backgrounds (search box) — reset to dark text/icons */ -.glass-enabled:not(.dark) .glass-outline, -.glass-enabled:not(.dark) .bg-sidebar-accent { - --sidebar-foreground: oklch(0.145 0 0); -} - -/* Sidebar search stays on the white-text treatment in light glass mode. */ -.glass-enabled:not(.dark) .sidebar-search-glass { - --sidebar-foreground: oklch(0.985 0 0); -} - -/* In light mode with glass enabled, selected chats should stay white. */ -.glass-enabled:not(.dark) .session-item-active { - color: oklch(0.985 0 0); -} - -/* In light glass mode, sidebar engine icons match white text via inversion */ -.glass-enabled:not(.dark) .session-item-button img { - filter: invert(1) brightness(2); + /* Light mode glass: keep dark text (same as Windows) — the always-key-appearance + fix keeps the glass bright enough for standard dark-on-light text. */ } /* In glass mode, only the outermost bg-sidebar (AppLayout root) provides diff --git a/src/lib/color-utils.test.ts b/src/lib/color-utils.test.ts new file mode 100644 index 0000000..9b8e975 --- /dev/null +++ b/src/lib/color-utils.test.ts @@ -0,0 +1,71 @@ +import { describe, it, expect } from "vitest"; +import { oklchToHexRGBA, computeGlassTintColor } from "./color-utils"; + +describe("oklchToHexRGBA", () => { + it("converts a known blue OKLCh to hex", () => { + const hex = oklchToHexRGBA(0.5, 0.15, 260, 0.2); + expect(hex).toMatch(/^#[0-9a-f]{8}$/); + // Alpha byte should be ~0x33 (0.2 * 255 ≈ 51) + const alphaByte = parseInt(hex.slice(7, 9), 16); + expect(alphaByte).toBeGreaterThanOrEqual(50); + expect(alphaByte).toBeLessThanOrEqual(52); + }); + + it("produces full opacity when alpha is 1", () => { + const hex = oklchToHexRGBA(0.5, 0.1, 30, 1); + expect(hex.slice(7, 9)).toBe("ff"); + }); + + it("defaults alpha to 1", () => { + const hex = oklchToHexRGBA(0.5, 0.1, 30); + expect(hex.slice(7, 9)).toBe("ff"); + }); + + it("clamps out-of-gamut values without crashing", () => { + // Extreme chroma can push sRGB channels out of [0,1] + const hex = oklchToHexRGBA(0.9, 0.4, 140, 0.5); + expect(hex).toMatch(/^#[0-9a-f]{8}$/); + }); + + it("handles NaN inputs gracefully", () => { + const hex = oklchToHexRGBA(NaN, 0.1, 30, 0.5); + expect(hex).toMatch(/^#[0-9a-f]{8}$/); + }); + + it("handles Infinity inputs gracefully", () => { + const hex = oklchToHexRGBA(0.5, Infinity, 30, 0.5); + expect(hex).toMatch(/^#[0-9a-f]{8}$/); + }); + + it("produces stable output for the same input", () => { + const a = oklchToHexRGBA(0.5, 0.12, 200, 0.15); + const b = oklchToHexRGBA(0.5, 0.12, 200, 0.15); + expect(a).toBe(b); + }); +}); + +describe("computeGlassTintColor", () => { + it("returns null when chroma is 0", () => { + expect(computeGlassTintColor({ hue: 260, chroma: 0 })).toBeNull(); + }); + + it("returns a hex string for non-zero chroma", () => { + const result = computeGlassTintColor({ hue: 260, chroma: 0.15 }); + expect(result).toMatch(/^#[0-9a-f]{8}$/); + }); + + it("produces different colors for different hues", () => { + const blue = computeGlassTintColor({ hue: 260, chroma: 0.15 }); + const red = computeGlassTintColor({ hue: 30, chroma: 0.15 }); + expect(blue).not.toBe(red); + }); + + it("increases alpha with higher chroma", () => { + const low = computeGlassTintColor({ hue: 200, chroma: 0.05 }); + const high = computeGlassTintColor({ hue: 200, chroma: 0.25 }); + // Extract alpha byte + const alphaLow = parseInt(low!.slice(7, 9), 16); + const alphaHigh = parseInt(high!.slice(7, 9), 16); + expect(alphaHigh).toBeGreaterThan(alphaLow); + }); +}); diff --git a/src/lib/color-utils.ts b/src/lib/color-utils.ts new file mode 100644 index 0000000..daa70ce --- /dev/null +++ b/src/lib/color-utils.ts @@ -0,0 +1,92 @@ +/** + * OKLCh → sRGB hex conversion utilities for native glass tinting. + * + * Pipeline: OKLCh (polar) → OKLab (cartesian) → linear sRGB (matrix) → sRGB (gamma) → hex + */ + +// OKLab → linear sRGB conversion matrix (from Björn Ottosson) +// https://bottosson.github.io/posts/oklab/ +function oklabToLinearSRGB(L: number, a: number, b: number): [number, number, number] { + const l_ = L + 0.3963377774 * a + 0.2158037573 * b; + const m_ = L - 0.1055613458 * a - 0.0638541728 * b; + const s_ = L - 0.0894841775 * a - 1.2914855480 * b; + + const l = l_ * l_ * l_; + const m = m_ * m_ * m_; + const s = s_ * s_ * s_; + + return [ + +4.0767416621 * l - 3.3077115913 * m + 0.2309699292 * s, + -1.2684380046 * l + 2.6097574011 * m - 0.3413193965 * s, + -0.0041960863 * l - 0.7034186147 * m + 1.7076147010 * s, + ]; +} + +// Linear sRGB → sRGB gamma correction +function linearToSRGB(x: number): number { + if (x <= 0.0031308) return 12.92 * x; + return 1.055 * Math.pow(x, 1 / 2.4) - 0.055; +} + +function clampByte(v: number): number { + const n = v * 255; + if (!Number.isFinite(n)) return 0; + return Math.max(0, Math.min(255, Math.round(n))); +} + +/** + * Convert an OKLCh color to a hex RGBA string (#RRGGBBAA). + * + * @param lightness OKLab L channel (0–1) + * @param chroma OKLCh C channel (0–~0.4) + * @param hueDeg OKLCh H channel in degrees (0–360) + * @param alpha Alpha (0–1, defaults to 1) + */ +export function oklchToHexRGBA( + lightness: number, + chroma: number, + hueDeg: number, + alpha = 1, +): string { + const hueRad = (hueDeg * Math.PI) / 180; + const a = chroma * Math.cos(hueRad); + const b = chroma * Math.sin(hueRad); + + const [lr, lg, lb] = oklabToLinearSRGB(lightness, a, b); + + const r = clampByte(linearToSRGB(lr)); + const g = clampByte(linearToSRGB(lg)); + const bCh = clampByte(linearToSRGB(lb)); + const aCh = clampByte(alpha); + + const hex = (v: number) => v.toString(16).padStart(2, "0"); + return `#${hex(r)}${hex(g)}${hex(bCh)}${hex(aCh)}`; +} + +function clamp01(value: number): number { + return Math.max(0, Math.min(1, value)); +} + +function getTintStrength(chroma: number): number { + const normalized = clamp01(chroma / 0.3); + return Math.pow(normalized, 0.8); +} + +/** + * Compute the native glass tint hex color for a space color. + * + * Returns null if no tint should be applied (chroma === 0). + * Uses slightly higher alpha than the CSS overlay because native glass + * blending absorbs more color than an additive CSS layer. + */ +export function computeGlassTintColor( + spaceColor: { hue: number; chroma: number }, +): string | null { + if (spaceColor.chroma === 0) return null; + + const tintStrength = getTintStrength(spaceColor.chroma); + const overlayChroma = Math.min(0.18, 0.04 + 0.12 * tintStrength); + const alpha = (0.04 + 0.12 * tintStrength) * 1.5; + + return oklchToHexRGBA(0.5, overlayChroma, spaceColor.hue, alpha); +} diff --git a/src/types/window.d.ts b/src/types/window.d.ts index 3cc7574..276b10f 100644 --- a/src/types/window.d.ts +++ b/src/types/window.d.ts @@ -50,6 +50,10 @@ declare global { claude: { getGlassSupported: () => Promise; setMinWidth: (width: number) => void; + glass: { + setTintColor: (tintColor: string | null) => void; + setTheme: (theme: "light" | "dark" | "system") => void; + }; start: (options?: { cwd?: string; model?: string;