diff --git a/src-tauri/Cargo.lock b/src-tauri/Cargo.lock
index 3f8e3ec5..d85ca08c 100644
--- a/src-tauri/Cargo.lock
+++ b/src-tauri/Cargo.lock
@@ -389,6 +389,12 @@ dependencies = [
"wyz",
]
+[[package]]
+name = "block"
+version = "0.1.6"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "0d8c1fef690941d3e7788d328517591fecc684c084084702d6ff1641e993699a"
+
[[package]]
name = "block-buffer"
version = "0.10.4"
@@ -700,6 +706,35 @@ dependencies = [
"cc",
]
+[[package]]
+name = "cocoa"
+version = "0.26.1"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "ad36507aeb7e16159dfe68db81ccc27571c3ccd4b76fb2fb72fc59e7a4b1b64c"
+dependencies = [
+ "bitflags 2.11.0",
+ "block",
+ "cocoa-foundation",
+ "core-foundation 0.10.1",
+ "core-graphics 0.24.0",
+ "foreign-types 0.5.0",
+ "libc",
+ "objc",
+]
+
+[[package]]
+name = "cocoa-foundation"
+version = "0.2.1"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "81411967c50ee9a1fc11365f8c585f863a22a9697c89239c452292c40ba79b0d"
+dependencies = [
+ "bitflags 2.11.0",
+ "block",
+ "core-foundation 0.10.1",
+ "core-graphics-types",
+ "objc",
+]
+
[[package]]
name = "combine"
version = "4.6.7"
@@ -770,6 +805,19 @@ version = "0.8.7"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "773648b94d0e5d620f64f280777445740e61fe701025087ec8b57f45c791888b"
+[[package]]
+name = "core-graphics"
+version = "0.24.0"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "fa95a34622365fa5bbf40b20b75dba8dfa8c94c734aea8ac9a5ca38af14316f1"
+dependencies = [
+ "bitflags 2.11.0",
+ "core-foundation 0.10.1",
+ "core-graphics-types",
+ "foreign-types 0.5.0",
+ "libc",
+]
+
[[package]]
name = "core-graphics"
version = "0.25.0"
@@ -1004,6 +1052,12 @@ dependencies = [
"windows-sys 0.61.2",
]
+[[package]]
+name = "dispatch"
+version = "0.2.0"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "bd0c93bb4b0c6d9b77f4435b0ae98c24d17f1c45b2ff844c6151a07256ca923b"
+
[[package]]
name = "dispatch2"
version = "0.3.1"
@@ -2488,6 +2542,15 @@ version = "0.1.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "c41e0c4fef86961ac6d6f8a82609f55f31b05e4fce149ac5710e439df7619ba4"
+[[package]]
+name = "malloc_buf"
+version = "0.0.6"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "62bb907fe88d54d8d9ce32a3cceab4218ed2f6b7d35617cafe9adf84e43919cb"
+dependencies = [
+ "libc",
+]
+
[[package]]
name = "markup5ever"
version = "0.14.1"
@@ -2731,6 +2794,15 @@ dependencies = [
"libc",
]
+[[package]]
+name = "objc"
+version = "0.2.7"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "915b1b472bc21c53464d6c8461c9d3af805ba1ef837e1cac254428f4a77177b1"
+dependencies = [
+ "malloc_buf",
+]
+
[[package]]
name = "objc2"
version = "0.6.4"
@@ -3076,6 +3148,7 @@ dependencies = [
"tauri-plugin-aptabase",
"tauri-plugin-autostart",
"tauri-plugin-global-shortcut",
+ "tauri-plugin-liquid-glass",
"tauri-plugin-log",
"tauri-plugin-opener",
"tauri-plugin-process",
@@ -4812,7 +4885,7 @@ dependencies = [
"bitflags 2.11.0",
"block2",
"core-foundation 0.10.1",
- "core-graphics",
+ "core-graphics 0.25.0",
"crossbeam-channel",
"dispatch2",
"dlopen2",
@@ -5067,6 +5140,24 @@ dependencies = [
"thiserror 2.0.18",
]
+[[package]]
+name = "tauri-plugin-liquid-glass"
+version = "0.1.6"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "59a86fd9cd6fc62cad37daa0d7f8be8f4986543d13dd30e4ff5cd2bc81ff91d0"
+dependencies = [
+ "cocoa",
+ "dispatch",
+ "log",
+ "objc",
+ "serde",
+ "serde_json",
+ "serde_repr",
+ "tauri",
+ "tauri-plugin",
+ "thiserror 2.0.18",
+]
+
[[package]]
name = "tauri-plugin-log"
version = "2.8.0"
diff --git a/src-tauri/Cargo.toml b/src-tauri/Cargo.toml
index 074c81ad..e49cec3d 100644
--- a/src-tauri/Cargo.toml
+++ b/src-tauri/Cargo.toml
@@ -39,12 +39,13 @@ tauri-plugin-global-shortcut = "2"
tauri-plugin-autostart = "2.5.1"
tokio = { version = "1", features = ["rt-multi-thread", "macros"] }
regex-lite = "0.1.9"
+tauri-plugin-liquid-glass = "0.1.6"
aes-gcm = "0.10.3"
[target.'cfg(target_os = "macos")'.dependencies]
objc2 = "0.6"
-objc2-foundation = { version = "0.3", features = ["NSProcessInfo", "NSString"] }
-objc2-app-kit = { version = "0.3", features = ["NSEvent", "NSScreen", "NSGraphics"] }
+objc2-foundation = { version = "0.3", features = ["NSKeyValueCoding", "NSProcessInfo", "NSString"] }
+objc2-app-kit = { version = "0.3", features = ["NSClipView", "NSColor", "NSEvent", "NSGraphics", "NSScreen", "NSScrollView", "NSView", "NSWindow"] }
objc2-web-kit = { version = "0.3", features = ["WKPreferences", "WKWebView", "WKWebViewConfiguration"] }
[dev-dependencies]
diff --git a/src-tauri/capabilities/default.json b/src-tauri/capabilities/default.json
index cc6aa864..a3f0af54 100644
--- a/src-tauri/capabilities/default.json
+++ b/src-tauri/capabilities/default.json
@@ -18,6 +18,7 @@
"process:allow-restart",
"global-shortcut:default",
"autostart:default",
+ "liquid-glass:default",
"core:menu:default"
]
}
diff --git a/src-tauri/src/lib.rs b/src-tauri/src/lib.rs
index 98b10d92..d74baa76 100644
--- a/src-tauri/src/lib.rs
+++ b/src-tauri/src/lib.rs
@@ -16,6 +16,7 @@ use std::sync::{Arc, Mutex, OnceLock};
use serde::Serialize;
use tauri::Emitter;
use tauri_plugin_aptabase::EventTracker;
+use tauri_plugin_liquid_glass::{GlassMaterialVariant, LiquidGlassConfig, LiquidGlassExt};
use tauri_plugin_log::{Target, TargetKind};
use uuid::Uuid;
@@ -201,6 +202,33 @@ fn hide_panel(app_handle: tauri::AppHandle) {
}
}
+#[tauri::command]
+fn set_liquid_glass_enabled(app_handle: tauri::AppHandle, enabled: bool) -> Result<(), String> {
+ use tauri::Manager;
+
+ let Some(window) = app_handle.get_webview_window("main") else {
+ return Ok(());
+ };
+
+ let config = if enabled {
+ LiquidGlassConfig {
+ corner_radius: 22.0,
+ variant: GlassMaterialVariant::Sidebar,
+ ..Default::default()
+ }
+ } else {
+ LiquidGlassConfig {
+ enabled: false,
+ ..Default::default()
+ }
+ };
+
+ app_handle
+ .liquid_glass()
+ .set_effect(&window, config)
+ .map_err(|error| error.to_string())
+}
+
#[tauri::command]
fn open_devtools(#[allow(unused)] app_handle: tauri::AppHandle) {
#[cfg(debug_assertions)]
@@ -492,9 +520,11 @@ pub fn run() {
.plugin(tauri_plugin_process::init())
.plugin(tauri_plugin_global_shortcut::Builder::new().build())
.plugin(tauri_plugin_autostart::Builder::new().build())
+ .plugin(tauri_plugin_liquid_glass::init())
.invoke_handler(tauri::generate_handler![
init_panel,
hide_panel,
+ set_liquid_glass_enabled,
open_devtools,
start_probe_batch,
list_plugins,
@@ -508,7 +538,7 @@ pub fn run() {
#[cfg(target_os = "macos")]
{
app_nap::disable_app_nap();
- webkit_config::disable_webview_suspension(app.handle());
+ webkit_config::configure_webview(app.handle());
}
use tauri::Manager;
diff --git a/src-tauri/src/webkit_config.rs b/src-tauri/src/webkit_config.rs
index f6f65191..3858b365 100644
--- a/src-tauri/src/webkit_config.rs
+++ b/src-tauri/src/webkit_config.rs
@@ -1,7 +1,8 @@
-//! WebKit configuration for disabling background suspension on macOS.
+//! WebKit configuration for macOS panel behavior.
//!
-//! By default, WebKit suspends JavaScript execution when the webview is not visible.
-//! This module disables that behavior so auto-update timers continue to fire.
+//! We keep JavaScript active while the panel is hidden and force the WKWebView
+//! itself fully transparent so native liquid-glass can show through the app
+//! container instead of only around it.
use tauri::Manager;
@@ -12,27 +13,64 @@ fn macos_at_least(major: u64, minor: u64) -> bool {
(version.majorVersion as u64, version.minorVersion as u64) >= (major, minor)
}
-pub fn disable_webview_suspension(app_handle: &tauri::AppHandle) {
+pub fn configure_webview(app_handle: &tauri::AppHandle) {
let Some(window) = app_handle.get_webview_window("main") else {
log::warn!("webkit_config: main window not found");
return;
};
- if !macos_at_least(14, 0) {
- log::info!("WebKit inactiveSchedulingPolicy requires macOS 14.0+; skipping on this system");
- return;
+ let can_disable_inactive_scheduling = macos_at_least(14, 0);
+ if !can_disable_inactive_scheduling {
+ log::info!(
+ "WebKit inactiveSchedulingPolicy requires macOS 14.0+; skipping scheduling override on this system"
+ );
}
- if let Err(e) = window.with_webview(|webview| {
- unsafe {
- use objc2_web_kit::{WKInactiveSchedulingPolicy, WKWebView};
- let wk_webview: &WKWebView = &*webview.inner().cast();
- let config = wk_webview.configuration();
- let prefs = config.preferences();
+ if let Err(e) = window.with_webview(move |webview| unsafe {
+ use objc2::sel;
+ use objc2_app_kit::NSColor;
+ use objc2_foundation::{NSNumber, NSObjectNSKeyValueCoding, NSObjectProtocol, ns_string};
+ use objc2_web_kit::{WKInactiveSchedulingPolicy, WKWebView};
+
+ let wk_webview: &WKWebView = &*webview.inner().cast();
+ let clear = NSColor::clearColor();
+ let no = NSNumber::numberWithBool(false);
+ let config = wk_webview.configuration();
+ let prefs = config.preferences();
+
+ if can_disable_inactive_scheduling {
prefs.setInactiveSchedulingPolicy(WKInactiveSchedulingPolicy::None);
- log::info!("WebKit inactiveSchedulingPolicy set to None");
+ }
+
+ config.setValue_forKey(Some(&no), ns_string!("drawsBackground"));
+ wk_webview.setValue_forKey(Some(&no), ns_string!("drawsBackground"));
+
+ if wk_webview.respondsToSelector(sel!(setUnderPageBackgroundColor:)) {
+ wk_webview.setUnderPageBackgroundColor(Some(&clear));
+ }
+
+ if let Some(scroll_view) = wk_webview.enclosingScrollView() {
+ scroll_view.setDrawsBackground(false);
+ scroll_view.setBackgroundColor(&clear);
+
+ let clip_view = scroll_view.contentView();
+ clip_view.setDrawsBackground(false);
+ clip_view.setBackgroundColor(&clear);
+ }
+
+ if let Some(ns_window) = wk_webview.window() {
+ ns_window.setOpaque(false);
+ ns_window.setBackgroundColor(Some(&clear));
+ }
+
+ if can_disable_inactive_scheduling {
+ log::info!("Configured transparent WKWebView and disabled inactive scheduling");
+ } else {
+ log::info!(
+ "Configured transparent WKWebView; inactive scheduling override not applied on this macOS version"
+ );
}
}) {
- log::warn!("Failed to configure WebKit scheduling: {e}");
+ log::warn!("Failed to configure WKWebView transparency/scheduling: {e}");
}
}
diff --git a/src/App.test.tsx b/src/App.test.tsx
index 9c54a0d0..52de726f 100644
--- a/src/App.test.tsx
+++ b/src/App.test.tsx
@@ -384,18 +384,44 @@ describe("App", () => {
// Dark
await userEvent.click(await screen.findByRole("radio", { name: "Dark" }))
expect(document.documentElement.classList.contains("dark")).toBe(true)
+ expect(document.documentElement.classList.contains("glass")).toBe(false)
// Light
await userEvent.click(await screen.findByRole("radio", { name: "Light" }))
expect(document.documentElement.classList.contains("dark")).toBe(false)
+ expect(document.documentElement.classList.contains("glass")).toBe(false)
+
+ // Glass
+ await userEvent.click(await screen.findByRole("radio", { name: "Glass" }))
+ expect(document.documentElement.classList.contains("dark")).toBe(false)
+ expect(document.documentElement.classList.contains("glass")).toBe(true)
// Back to system should subscribe to matchMedia changes
await userEvent.click(await screen.findByRole("radio", { name: "System" }))
expect(mq.addEventListener).toHaveBeenCalled()
+ expect(document.documentElement.classList.contains("glass")).toBe(false)
mmSpy.mockRestore()
})
+ it("syncs native liquid glass mode when running in tauri", async () => {
+ state.isTauriMock.mockReturnValue(true)
+
+ render()
+ const settingsButtons = await screen.findAllByRole("button", { name: "Settings" })
+ await userEvent.click(settingsButtons[0])
+
+ await userEvent.click(await screen.findByRole("radio", { name: "Glass" }))
+ await waitFor(() =>
+ expect(state.invokeMock).toHaveBeenCalledWith("set_liquid_glass_enabled", { enabled: true })
+ )
+
+ await userEvent.click(await screen.findByRole("radio", { name: "Light" }))
+ await waitFor(() =>
+ expect(state.invokeMock).toHaveBeenCalledWith("set_liquid_glass_enabled", { enabled: false })
+ )
+ })
+
it("loads plugins, normalizes settings, and renders overview", async () => {
state.isTauriMock.mockReturnValue(true)
render()
@@ -778,6 +804,7 @@ describe("App", () => {
// because "b" is not in DEFAULT_ENABLED_PLUGINS = ["claude","codex","cursor"])
state.loadPluginSettingsMock.mockResolvedValue({ order: ["a", "b"], disabled: ["b"] })
render()
+ await waitFor(() => expect(state.invokeMock).toHaveBeenCalledWith("list_plugins"))
const settingsButtons = await screen.findAllByRole("button", { name: "Settings" })
await userEvent.click(settingsButtons[0])
// Re-query before each click: the Checkbox remounts on each toggle because
diff --git a/src/components/app/app-shell.tsx b/src/components/app/app-shell.tsx
index 8de76733..7bdeff6a 100644
--- a/src/components/app/app-shell.tsx
+++ b/src/components/app/app-shell.tsx
@@ -9,8 +9,6 @@ import { usePanel } from "@/hooks/app/use-panel"
import { useAppUpdate } from "@/hooks/use-app-update"
import { useAppUiStore } from "@/stores/app-ui-store"
-const ARROW_OVERHEAD_PX = 37
-
type AppShellProps = {
onRefreshAll: () => void
navPlugins: NavPlugin[]
@@ -67,15 +65,10 @@ export function AppShell({
const { updateStatus, triggerInstall, checkForUpdates } = useAppUpdate()
return (
-
-
+
-
+
Global Shortcut
-
+
Show panel from anywhere
@@ -258,9 +258,9 @@ export function GlobalShortcutSection({
onKeyUp={handleKeyUp}
onBlur={handleBlur}
className={cn(
- "w-full h-8 px-3 text-sm rounded-md border-2 border-primary bg-muted/50",
+ "settings-input-surface w-full h-8 px-3 text-sm rounded-md border-2 border-primary",
"flex items-center outline-none",
- !pendingDisplay && "text-muted-foreground"
+ !pendingDisplay && "settings-copy"
)}
>
{getDisplayValue()}
@@ -268,9 +268,9 @@ export function GlobalShortcutSection({
) : (
{ if (e.key === "Enter" || e.key === " ") startRecording() }}
@@ -288,12 +288,12 @@ export function GlobalShortcutSection({
) : (
- Click to set
+ Click to set
)}
)}
-
+
Press Escape while recording to clear.
diff --git a/src/components/provider-card.tsx b/src/components/provider-card.tsx
index 2b02722c..29866cbb 100644
--- a/src/components/provider-card.tsx
+++ b/src/components/provider-card.tsx
@@ -304,9 +304,9 @@ function MetricLineRenderer({
return (
- {line.label}
+ {line.label}
@@ -314,7 +314,7 @@ function MetricLineRenderer({
{line.subtitle && (
-
{line.subtitle}
+
{line.subtitle}
)}
)
@@ -324,7 +324,7 @@ function MetricLineRenderer({
return (
- {line.label}
+ {line.label}
{line.subtitle && (
-
{line.subtitle}
+
{line.subtitle}
)}
)
@@ -443,7 +443,7 @@ function MetricLineRenderer({
markerValue={paceMarkerValue}
/>
-
+
{primaryText}
{secondaryText && (
@@ -456,12 +456,12 @@ function MetricLineRenderer({
{...props}
type="button"
onClick={onResetTimerDisplayModeToggle}
- className="text-xs text-muted-foreground tabular-nums hover:text-foreground transition-colors"
+ className="text-xs text-foreground/70 tabular-nums hover:text-foreground transition-colors"
>
{secondaryText}
) : (
-
+
{secondaryText}
)
@@ -473,12 +473,12 @@ function MetricLineRenderer({
) : (
-
+
{secondaryText}
)
@@ -487,12 +487,12 @@ function MetricLineRenderer({
{(deficitText || runsOutText) && (
{deficitText && (
-
+
{deficitText}
)}
{runsOutText && (
-
+
{runsOutText}
)}
diff --git a/src/components/side-nav.tsx b/src/components/side-nav.tsx
index b363e888..5eed9e56 100644
--- a/src/components/side-nav.tsx
+++ b/src/components/side-nav.tsx
@@ -66,10 +66,10 @@ function NavButton({ isActive, onClick, onContextMenu, children, "aria-label": a
onContextMenu={onContextMenu}
aria-label={ariaLabel}
className={cn(
- "relative flex items-center justify-center w-full p-2.5 transition-colors",
+ "relative mx-px flex items-center justify-center w-[calc(100%-2px)] rounded-[8px] p-2.5 transition-colors",
"hover:bg-accent",
isActive
- ? "text-foreground before:absolute before:left-0 before:top-1.5 before:bottom-1.5 before:w-0.5 before:bg-primary dark:before:bg-page-accent before:rounded-full"
+ ? "text-foreground before:absolute before:left-[-1px] before:top-1.5 before:bottom-1.5 before:w-0.5 before:bg-primary dark:before:bg-page-accent before:rounded-full"
: "text-muted-foreground"
)}
>
@@ -215,7 +215,7 @@ export function SideNav({
)
return (
-