From a3a5859d90ae7ed303c0100a763329ed6c165d30 Mon Sep 17 00:00:00 2001 From: zebus3d Date: Fri, 9 Oct 2026 10:52:16 -0300 Subject: [PATCH 1/3] feat: add a setting to hide the window outline The frameless window draws a hairline edge (a border plus an inset ring) so it stands apart from whatever is behind it. On desktops that already separate windows well, such as KDE Plasma with a dark wallpaper, that line reads as a stray white border around the app. "Window outline" in Settings > Window turns it off. It defaults to on, so nothing changes for anyone who does not touch it, and it is hidden under OS native decorations, where the outline is already dropped. --- src/ui/App.tsx | 9 +++++++-- src/ui/components/settingsSearchIndex.ts | 1 + src/ui/pages/SettingsPage.tsx | 13 +++++++++++++ src/ui/settings/windowControls.ts | 17 +++++++++++++++++ 4 files changed, 38 insertions(+), 2 deletions(-) diff --git a/src/ui/App.tsx b/src/ui/App.tsx index 234701b2..4430dac4 100644 --- a/src/ui/App.tsx +++ b/src/ui/App.tsx @@ -46,7 +46,7 @@ import { TitleBar } from "./components/TitleBar"; import { PlayerBar } from "./components/player/PlayerBar"; import { QueuePanel } from "./components/player/QueuePanel"; import { useQueuePanelCollapsed } from "./settings/queuePanel"; -import { useNativeWindowControls } from "./settings/windowControls"; +import { useNativeWindowControls, useWindowOutline } from "./settings/windowControls"; /** Wide enough for a 44px cover plus breathing room, matching the sidebar rail's feel. */ const COLLAPSED_QUEUE_WIDTH = 62; @@ -377,6 +377,7 @@ export default function App() { const [queuePanelWidth, setQueuePanelWidth] = useState(340); const isQueuePanelCollapsed = useQueuePanelCollapsed(); const nativeWindowControls = useNativeWindowControls(); + const windowOutline = useWindowOutline(); const [loadingScreenState, setLoadingScreenState] = useState<"visible" | "leaving" | "hidden">("visible"); const [onboardingComplete, setOnboardingComplete] = useState(() => readLocalOnboardingComplete() ? true : null @@ -1987,10 +1988,14 @@ useEffect(() => { Both drop out under OS native decorations: the WM already draws a real frame above the webview there, so this edge would just be a stray line under the OS title bar. + The "Window outline" setting can also turn it off for desktops where it reads as a + white border rather than an edge. */}
{/* {!paperPcMode && } diff --git a/src/ui/components/settingsSearchIndex.ts b/src/ui/components/settingsSearchIndex.ts index 3c4b9846..2eeed988 100644 --- a/src/ui/components/settingsSearchIndex.ts +++ b/src/ui/components/settingsSearchIndex.ts @@ -38,6 +38,7 @@ const ENTRIES: SettingsSearchEntry[] = [ { tab: "window", category: "Window", title: "Window controls", description: "Choose macOS, Windows, or native title-bar buttons." }, { tab: "window", category: "Window", title: "Compact player bar", description: "Use a smaller playback bar." }, { tab: "window", category: "Window", title: "System media controls", description: "Show playback in system media controls." }, + { tab: "window", category: "Window", title: "Window outline", description: "Show or hide the thin edge around the window." }, { tab: "shortcuts", category: "Shortcuts", title: "Keyboard shortcuts", description: "View, record, or clear keyboard bindings." }, ]; diff --git a/src/ui/pages/SettingsPage.tsx b/src/ui/pages/SettingsPage.tsx index 10f3d6e3..1712e933 100644 --- a/src/ui/pages/SettingsPage.tsx +++ b/src/ui/pages/SettingsPage.tsx @@ -127,9 +127,11 @@ import { import { setForceWindowControls, setNativeWindowControls, + setWindowOutline, setWindowsStyleWindowControls, useForceWindowControls, useNativeWindowControls, + useWindowOutline, useWindowsStyleWindowControls, } from "../settings/windowControls"; import { @@ -647,6 +649,7 @@ export function SettingsPage({ const windowsStyleWindowControls = useWindowsStyleWindowControls(); const nativeWindowControls = useNativeWindowControls(); const forceWindowControls = useForceWindowControls(); + const windowOutline = useWindowOutline(); const tilingWindowManager = useSyncExternalStore( subscribeTilingWindowManager, isTilingWindowManager, @@ -2022,6 +2025,16 @@ export function SettingsPage({ /> )} + {/* The OS frame replaces the outline, so the switch would do nothing there. */} + {windowControlStyle !== "native" && ( + + )} + {isLinux && ( applyNativeWindowControls(), ), hydrateLocalBooleanSetting(FORCE_CONTROLS_STORAGE_KEY, false, CHANGE_EVENT), + hydrateLocalBooleanSetting(WINDOW_OUTLINE_STORAGE_KEY, true, CHANGE_EVENT), ]); } @@ -101,3 +114,7 @@ export function useNativeWindowControls() { export function useForceWindowControls() { return useSyncExternalStore(subscribe, readForceWindowControls, () => false); } + +export function useWindowOutline() { + return useSyncExternalStore(subscribe, readWindowOutline, () => true); +} From 30ff2fb8f77a4b4707f5b917a6ee153da31430b0 Mon Sep 17 00:00:00 2001 From: zebus3d Date: Fri, 9 Oct 2026 10:57:57 -0300 Subject: [PATCH 2/3] fix: restore the main window at the size it was saved The window geometry was saved with outerSize but restored with setSize, which sets the inner size. With a frameless window the two are the same, but with OS native decorations the window grew by the height of the frame on every launch. It now saves the inner size, so both sides measure the same thing. --- src/ui/settings/mainWindowGeometry.ts | 4 +++- 1 file changed, 3 insertions(+), 1 deletion(-) diff --git a/src/ui/settings/mainWindowGeometry.ts b/src/ui/settings/mainWindowGeometry.ts index 69f33387..fabc4816 100644 --- a/src/ui/settings/mainWindowGeometry.ts +++ b/src/ui/settings/mainWindowGeometry.ts @@ -92,9 +92,11 @@ async function isGeometryOnAnyMonitor(geometry: MainWindowGeometry): Promise { const win = getCurrentWindow(); + // Inner size, because that is what `setSize` restores. Saving the outer size grew the + // window by the height of the OS frame on every launch with native decorations on. const [position, size, isMaximized, isFullscreen] = await Promise.all([ win.outerPosition(), - win.outerSize(), + win.innerSize(), win.isMaximized(), win.isFullscreen(), ]); From b5a20194112d980ad41e7e25772b64e78bb3fa73 Mon Sep 17 00:00:00 2001 From: zebus3d Date: Fri, 9 Oct 2026 11:22:29 -0300 Subject: [PATCH 3/3] feat: move "Minimize to tray" to the Window settings The switch lived on the Library tab, under "Cache and local files", where nobody looking for what the close button does would find it. It now sits on the Window tab, right below the window controls, since it decides what the close button does. The settings search entry moves with it. --- src/ui/components/settingsSearchIndex.ts | 2 +- src/ui/pages/SettingsPage.tsx | 17 ++++++++--------- 2 files changed, 9 insertions(+), 10 deletions(-) diff --git a/src/ui/components/settingsSearchIndex.ts b/src/ui/components/settingsSearchIndex.ts index 2eeed988..40476ea0 100644 --- a/src/ui/components/settingsSearchIndex.ts +++ b/src/ui/components/settingsSearchIndex.ts @@ -30,12 +30,12 @@ const ENTRIES: SettingsSearchEntry[] = [ { tab: "system", category: "Library", title: "Download quality", description: "Choose quality for offline music." }, { tab: "system", category: "Library", title: "Lyrics", description: "Translate lyrics, set text size, and choose a source." }, { tab: "system", category: "Library", title: "Launch at startup", description: "Start Zuno when your computer starts." }, - { tab: "system", category: "Library", title: "Minimize to tray", description: "Keep Zuno available from the system tray." }, { tab: "system", category: "Library", title: "Remember window size and location", description: "Restore the main window geometry." }, { tab: "system", category: "Library", title: "Troubleshooting", description: "Open logs or reset Zuno data." }, { tab: "window", category: "Window", title: "Mini player", description: "Show a compact player when the main window loses focus." }, { tab: "window", category: "Window", title: "Library sidebar", description: "Choose the playlist rail size and hover behavior." }, { tab: "window", category: "Window", title: "Window controls", description: "Choose macOS, Windows, or native title-bar buttons." }, + { tab: "window", category: "Window", title: "Minimize to tray", description: "Keep Zuno available from the system tray." }, { tab: "window", category: "Window", title: "Compact player bar", description: "Use a smaller playback bar." }, { tab: "window", category: "Window", title: "System media controls", description: "Show playback in system media controls." }, { tab: "window", category: "Window", title: "Window outline", description: "Show or hide the thin edge around the window." }, diff --git a/src/ui/pages/SettingsPage.tsx b/src/ui/pages/SettingsPage.tsx index 1712e933..e9fb5e8e 100644 --- a/src/ui/pages/SettingsPage.tsx +++ b/src/ui/pages/SettingsPage.tsx @@ -1758,15 +1758,6 @@ export function SettingsPage({ {autostartError &&

{autostartError}

} - - - - + {/* Next to the window buttons, because it decides what the close button does. */} + + {/* Only reachable when it does something: hidden once native chrome takes over, and off tiling compositors the buttons already show without this. */} {isLinux && tilingWindowManager && windowControlStyle !== "native" && (