From 012cb77cea1f8111b9b78c4810d160e263f0d88a Mon Sep 17 00:00:00 2001 From: HKLHaoBin Date: Fri, 12 Jun 2026 23:15:12 +0800 Subject: [PATCH] =?UTF-8?q?=E7=BB=9F=E4=B8=80=E5=8D=A1=E7=89=87=20UI=20?= =?UTF-8?q?=E8=AE=BE=E8=AE=A1=EF=BC=8C=E4=BC=98=E5=8C=96=E4=B8=BB=E9=A2=98?= =?UTF-8?q?=E5=88=87=E6=8D=A2=E4=B8=8E=E8=83=8C=E6=99=AF=E6=A0=B7=E5=BC=8F?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../app/scripts/aura-skin-contract.test.mjs | 24 ++++++ .../app/scripts/windows-ui-config.test.mjs | 55 ++++++++++--- openless-all/app/src-tauri/src/lib.rs | 82 ++++++++++++++----- openless-all/app/src/components/Capsule.tsx | 4 +- openless-all/app/src/lib/platform.ts | 18 ++++ openless-all/app/src/lib/themeMode.ts | 13 ++- .../app/src/pages/settings/shared.tsx | 4 +- openless-all/app/src/styles/global.css | 22 ++--- openless-all/app/src/styles/tokens.css | 37 +++++++++ 9 files changed, 212 insertions(+), 47 deletions(-) diff --git a/openless-all/app/scripts/aura-skin-contract.test.mjs b/openless-all/app/scripts/aura-skin-contract.test.mjs index 57962599..4e6b1c99 100644 --- a/openless-all/app/scripts/aura-skin-contract.test.mjs +++ b/openless-all/app/scripts/aura-skin-contract.test.mjs @@ -338,6 +338,30 @@ for (const pair of remoteMutedContrastPairs) { remoteMutedContrastRatios[pair.label] = assertMutedContrast(pair.tokens, pair.label, pair.bg, pair.ink); } +const olFrostBlock = extractCssBlock(globalCss, '.ol-frost'); +assert.doesNotMatch( + olFrostBlock, + /rgba\(\s*255\s*,\s*255\s*,\s*255/i, + '.ol-frost in global.css must not hardcode white rgba background gradients', +); +assert.match(globalCss, /background:\s*var\(--ol-frost-bg\)/, '.ol-frost must use --ol-frost-bg token'); + +assert.match( + tokens, + /\[data-ol-theme='dark'\][\s\S]*--ol-input-ink:/, + 'tokens.css must define --ol-input-ink in dark theme', +); +assert.match( + tokens, + /\[data-ol-theme='dark'\][\s\S]*--ol-capsule-confirm-bg:/, + 'tokens.css must define --ol-capsule-confirm-bg in dark theme', +); +assert.match( + tokens, + /\[data-ol-theme='dark'\][\s\S]*--ol-capsule-confirm-ink:/, + 'tokens.css must define --ol-capsule-confirm-ink in dark theme', +); + assert.match(globalCss, /\.ol-app-shell-bg\b/, 'global.css must expose .ol-app-shell-bg'); assert.match(globalCss, /\.ol-aura-panel\b/, 'global.css must expose .ol-aura-panel'); assert.doesNotMatch(globalCss, /@keyframes ol-aura-halo/, 'global.css must not add an animated halo'); diff --git a/openless-all/app/scripts/windows-ui-config.test.mjs b/openless-all/app/scripts/windows-ui-config.test.mjs index 4d9ddb61..c83f64ad 100644 --- a/openless-all/app/scripts/windows-ui-config.test.mjs +++ b/openless-all/app/scripts/windows-ui-config.test.mjs @@ -22,7 +22,8 @@ const capsuleTsx = await readFile(new URL('../src/components/Capsule.tsx', impor const capsuleLayoutTs = await readFile(new URL('../src/lib/capsuleLayout.ts', import.meta.url), 'utf-8'); const windowChromeTsx = await readFile(new URL('../src/components/WindowChrome.tsx', import.meta.url), 'utf-8'); const floatingShellTsx = await readFile(new URL('../src/components/FloatingShell.tsx', import.meta.url), 'utf-8'); -const tokensCss = await readFile(new URL('../src/styles/tokens.css', import.meta.url), 'utf-8'); +const themeModeTs = await readFile(new URL('../src/lib/themeMode.ts', import.meta.url), 'utf-8'); +const platformTs = await readFile(new URL('../src/lib/platform.ts', import.meta.url), 'utf-8'); if (!capsuleWindow) { throw new Error('capsule window config missing'); @@ -34,13 +35,31 @@ assertEqual(capsuleWindow.width, 220, 'windows capsule config keeps translation- assertEqual(capsuleWindow.height, 110, 'windows capsule config keeps translation-capable height baseline'); assertEqual(capsuleWindow.transparent, true, 'capsule window should keep transparent visuals'); assertEqual(capsuleWindow.alwaysOnTop, true, 'capsule window should stay above the focused app while recording'); -assertEqual(mainWindow.decorations, true, 'shared main window config should keep native macOS traffic lights'); +assertEqual(mainWindow.decorations, true, 'windows main window should keep native decorations'); assertEqual(mainWindow.visible, false, 'windows main window should stay hidden until the intended first show point'); assertMatch( libRs, - /#\[cfg\(target_os = "windows"\)\][\s\S]*?main\.set_decorations\(false\)/, - 'windows runtime should disable native chrome before the first show', + /fn apply_windows_caption_theme[\s\S]*?DWMWA_USE_IMMERSIVE_DARK_MODE[\s\S]*?DWMWA_CAPTION_COLOR[\s\S]*?DWMWA_TEXT_COLOR[\s\S]*?DWMWA_BORDER_COLOR/, + 'windows runtime should sync immersive dark mode and caption/text/border colors', +); + +assertMatch( + libRs, + /#\[tauri::command\][\s\S]*?fn set_windows_caption_theme/, + 'windows caption theme should be exposed as a Tauri command', +); + +assertMatch( + themeModeTs, + /export function applyThemeMode[\s\S]*?syncWindowsCaptionTheme/, + 'applyThemeMode should sync Windows native caption theme', +); + +assertMatch( + platformTs, + /export async function syncWindowsCaptionTheme[\s\S]*?set_windows_caption_theme/, + 'platform IPC wrapper should invoke set_windows_caption_theme', ); assertMatch( @@ -49,10 +68,18 @@ assertMatch( 'macOS capsule should show without taking the key window', ); -if (!/function WindowsResizeHandles\(\)/.test(windowChromeTsx)) { - throw new Error('windows frameless shell should expose explicit resize handles'); +const tokensCss = await readFile(new URL('../src/styles/tokens.css', import.meta.url), 'utf-8'); + +if (!/os === 'win' \|\| os === 'android' \? 0 : 14/.test(windowChromeTsx)) { + throw new Error('windows main shell should rely on native decorations instead of a frameless chrome shell'); } +assertMatch( + windowChromeTsx, + /\/\/ Windows: decorations:true 时外层不画圆角/, + 'windows WindowChrome should defer chrome to native decorations', +); + assertMatch( windowChromeTsx, /const MAC_TITLEBAR_HEIGHT = 28;/, @@ -66,8 +93,8 @@ assertMatch( if (/standardWindowButton|setFrameOrigin: origin|tune_macos_main_window_controls/.test(libRs)) { throw new Error('macOS traffic lights should not be manually repositioned; keep native AppKit button frames visible'); } -if (!/action=\"close\"/.test(windowChromeTsx) || !/tone=\"danger\"/.test(windowChromeTsx)) { - throw new Error('windows titlebar should keep the close button and danger hover treatment'); +if (!/className=\"ol-linux-close-btn\"/.test(windowChromeTsx)) { + throw new Error('linux titlebar should keep the close button treatment'); } assertMatch( tokensCss, @@ -75,12 +102,14 @@ assertMatch( 'shared motion tokens should drive shell animations and transitions', ); -if (!/startResizeDragging\(direction\)/.test(windowChromeTsx)) { - throw new Error('windows resize handles should delegate edge dragging to Tauri'); -} +assertMatch( + windowChromeTsx, + /function LinuxTitlebar\(\)/, + 'linux should keep the custom ol-linux-titlebar shell', +); -if (!/borderRadius:\s*'var\(--ol-window-console-radius\)'/.test(floatingShellTsx)) { - throw new Error('floating shell should consume the shared window-console radius'); +if (!/borderRadius:\s*'var\(--ol-r-lg\)'/.test(floatingShellTsx)) { + throw new Error('floating shell should consume the shared radius token'); } assertMatch( diff --git a/openless-all/app/src-tauri/src/lib.rs b/openless-all/app/src-tauri/src/lib.rs index 021ee9fa..b08e0f2e 100644 --- a/openless-all/app/src-tauri/src/lib.rs +++ b/openless-all/app/src-tauri/src/lib.rs @@ -281,6 +281,7 @@ macro_rules! app_invoke_handler_desktop { commands::sherpa_onnx_asr_reveal_model_dir, commands::export_error_log, restart_app, + set_windows_caption_theme, ] }; } @@ -507,9 +508,9 @@ fn run_desktop() { if let Err(e) = apply_mica(&main, None) { log::warn!("[main] mica failed: {e}"); } - // Win11 22H2+: 把原生标题栏底色调成白色,与应用 sidebar 视觉统一。 + // Win11 22H2+: 同步原生标题栏主题;前端就绪后会再调 set_windows_caption_theme。 // 老版 Windows 静默失败,不阻塞。 - apply_windows_caption_color(&main); + apply_windows_caption_theme(&main, false); } // 静默启动开关:prefs.start_minimized = true → 不弹主窗口, // 用户从菜单栏 / 托盘点击访问。开机自启时尤其有用,避免每次 @@ -957,41 +958,84 @@ pub(crate) fn refresh_tray_microphone_menu(_app: &AppHandle) -> tauri::Result<() Ok(()) } -/// 把 Win11 原生标题栏底色刷成白色,与应用 sidebar 视觉统一。需要 Win11 22H2+ -/// (Build 22621+) 才支持 `DWMWA_CAPTION_COLOR`(35);老 Windows 上 DwmSetWindowAttribute -/// 返回错误,仅打 warn 不阻塞启动。 +/// Win11 22H2+ (Build 22621+) 同步原生标题栏沉浸式暗色 / caption / text / border 色。 +/// 老 Windows 上 DwmSetWindowAttribute 返回错误,仅打 warn 不阻塞启动。 #[cfg(target_os = "windows")] -fn apply_windows_caption_color(window: &tauri::WebviewWindow) { +fn apply_windows_caption_theme(window: &tauri::WebviewWindow, dark: bool) { use raw_window_handle::{HasWindowHandle, RawWindowHandle}; use windows::Win32::Foundation::HWND; - use windows::Win32::Graphics::Dwm::{DwmSetWindowAttribute, DWMWA_CAPTION_COLOR}; + use windows::Win32::Graphics::Dwm::{ + DwmSetWindowAttribute, DWMWA_BORDER_COLOR, DWMWA_CAPTION_COLOR, DWMWA_TEXT_COLOR, + DWMWA_USE_IMMERSIVE_DARK_MODE, + }; let handle = match window.window_handle().map(|h| h.as_raw()) { Ok(RawWindowHandle::Win32(handle)) => handle, Ok(other) => { - log::warn!("[main] unexpected raw window handle for caption color: {other:?}"); + log::warn!("[main] unexpected raw window handle for caption theme: {other:?}"); return; } Err(e) => { - log::warn!("[main] read raw window handle for caption color failed: {e}"); + log::warn!("[main] read raw window handle for caption theme failed: {e}"); return; } }; let hwnd = HWND(handle.hwnd.get() as *mut core::ffi::c_void); - // COLORREF 0x00BBGGRR 编码——选用 rgb(245,245,247) 跟 WindowChrome 的 glass linear-gradient - // 起始色一致,减小原生 caption bar 跟应用磨砂玻璃的色差(用户反馈:纯白 caption + 半透灰 glass - // 色差很丑)。R=0xF5 G=0xF5 B=0xF7 → COLORREF = 0x00F7F5F5。 - let glass_match: u32 = 0x00F7F5F5; + // COLORREF 0x00BBGGRR — light 对齐 WindowChrome glass 起始色 rgb(245,245,247); + // dark 对齐 tokens.css --ol-surface (#141922) / --ol-ink (#f4f7fb) / --ol-surface-2 (#1a202b)。 + let immersive_dark: i32 = i32::from(dark); + let caption_color: u32 = if dark { 0x0022_1914 } else { 0x00F7_F5F5 }; + let text_color: u32 = if dark { 0x00FB_F7F4 } else { 0x002A_170F }; + let border_color: u32 = if dark { 0x002B_201A } else { 0x00E8_E8E8 }; + unsafe { - if let Err(e) = DwmSetWindowAttribute( + set_dwm_window_attribute( + hwnd, + DWMWA_USE_IMMERSIVE_DARK_MODE, + &immersive_dark, + "immersive dark mode", + ); + set_dwm_window_attribute( hwnd, DWMWA_CAPTION_COLOR, - &glass_match as *const _ as *const core::ffi::c_void, - std::mem::size_of_val(&glass_match) as u32, - ) { - log::warn!("[main] set caption color failed (likely pre-22H2 Win): {e}"); - } + &caption_color, + "caption color", + ); + set_dwm_window_attribute(hwnd, DWMWA_TEXT_COLOR, &text_color, "text color"); + set_dwm_window_attribute(hwnd, DWMWA_BORDER_COLOR, &border_color, "border color"); + } +} + +#[cfg(target_os = "windows")] +unsafe fn set_dwm_window_attribute( + hwnd: windows::Win32::Foundation::HWND, + attribute: windows::Win32::Graphics::Dwm::DWMWINDOWATTRIBUTE, + value: &T, + label: &str, +) { + use windows::Win32::Graphics::Dwm::DwmSetWindowAttribute; + + if let Err(e) = DwmSetWindowAttribute( + hwnd, + attribute, + value as *const _ as *const core::ffi::c_void, + std::mem::size_of_val(value) as u32, + ) { + log::warn!("[main] set {label} failed (likely pre-22H2 Win): {e}"); + } +} + +/// 前端主题切换时同步主窗口原生标题栏;非 Windows 为 no-op。 +#[tauri::command] +fn set_windows_caption_theme(app: AppHandle, dark: bool) { + #[cfg(target_os = "windows")] + if let Some(main) = app.get_webview_window("main") { + apply_windows_caption_theme(&main, dark); + } + #[cfg(not(target_os = "windows"))] + { + let _ = (app, dark); } } diff --git a/openless-all/app/src/components/Capsule.tsx b/openless-all/app/src/components/Capsule.tsx index f2b7323a..091dc104 100644 --- a/openless-all/app/src/components/Capsule.tsx +++ b/openless-all/app/src/components/Capsule.tsx @@ -114,10 +114,10 @@ function CircleButton({ variant, enabled, onClick }: CircleButtonProps) { width: 28, height: 28, borderRadius: 'var(--ol-pill-radius)', - background: isCancel ? 'var(--ol-control-muted-strong)' : 'var(--ol-control-solid)', + background: isCancel ? 'var(--ol-capsule-cancel-bg)' : 'var(--ol-capsule-confirm-bg)', backdropFilter: useBackdrop ? 'blur(12px) saturate(160%)' : 'none', WebkitBackdropFilter: useBackdrop ? 'blur(12px) saturate(160%)' : 'none', - color: 'var(--ol-ink)', + color: isCancel ? 'var(--ol-capsule-cancel-ink)' : 'var(--ol-capsule-confirm-ink)', border: '0.8px solid var(--ol-glass-border)', display: 'inline-flex', alignItems: 'center', diff --git a/openless-all/app/src/lib/platform.ts b/openless-all/app/src/lib/platform.ts index 03badcdb..3e91c6d2 100644 --- a/openless-all/app/src/lib/platform.ts +++ b/openless-all/app/src/lib/platform.ts @@ -120,3 +120,21 @@ export async function getPlatformCapabilities(): Promise { export function getCachedPlatformCapabilities(): PlatformCapabilities | null { return cachedCapabilities; } + +/** Win11 原生标题栏暗色同步;非 Windows / 非 Tauri 为 no-op。 */ +export async function syncWindowsCaptionTheme(dark: boolean): Promise { + if (typeof window === 'undefined') return; + if (detectOS() !== 'win') return; + + const isTauri = + globalThis.window !== undefined && + '__TAURI_INTERNALS__' in globalThis.window; + if (!isTauri) return; + + try { + const { invoke } = await import('@tauri-apps/api/core'); + await invoke('set_windows_caption_theme', { dark }); + } catch (err) { + console.warn('[platform] set_windows_caption_theme failed', err); + } +} diff --git a/openless-all/app/src/lib/themeMode.ts b/openless-all/app/src/lib/themeMode.ts index 14f2ba65..5c1138a5 100644 --- a/openless-all/app/src/lib/themeMode.ts +++ b/openless-all/app/src/lib/themeMode.ts @@ -1,6 +1,9 @@ // 主题模式 — localStorage 持久化,通过 html[data-ol-theme] 切换暗色 token 集。 +import { syncWindowsCaptionTheme } from './platform'; + export type ThemeMode = 'system' | 'light' | 'dark'; +export type ResolvedThemeMode = 'light' | 'dark'; const THEME_MODE_KEY = 'ol-theme-mode'; @@ -8,6 +11,12 @@ function systemPrefersDark(): boolean { return window.matchMedia('(prefers-color-scheme: dark)').matches; } +export function resolveThemeMode(mode: ThemeMode): ResolvedThemeMode { + if (mode === 'dark') return 'dark'; + if (mode === 'light') return 'light'; + return systemPrefersDark() ? 'dark' : 'light'; +} + export function readThemeMode(): ThemeMode { try { const v = window.localStorage.getItem(THEME_MODE_KEY); @@ -17,12 +26,14 @@ export function readThemeMode(): ThemeMode { } export function applyThemeMode(mode: ThemeMode): void { + const resolved = resolveThemeMode(mode); const root = document.documentElement; - if (mode === 'dark' || (mode === 'system' && systemPrefersDark())) { + if (resolved === 'dark') { root.dataset.olTheme = 'dark'; } else { delete root.dataset.olTheme; } + void syncWindowsCaptionTheme(resolved === 'dark'); } export function setThemeMode(mode: ThemeMode): ThemeMode { diff --git a/openless-all/app/src/pages/settings/shared.tsx b/openless-all/app/src/pages/settings/shared.tsx index 78e556fa..952e3739 100644 --- a/openless-all/app/src/pages/settings/shared.tsx +++ b/openless-all/app/src/pages/settings/shared.tsx @@ -137,7 +137,9 @@ export const inputStyle: CSSProperties = { fontSize: 12.5, fontFamily: "inherit", outline: "none", - background: "var(--ol-surface-2)", + background: "var(--ol-input-bg)", + color: "var(--ol-input-ink)", + caretColor: "var(--ol-input-ink)", width: "100%", maxWidth: 360, transition: diff --git a/openless-all/app/src/styles/global.css b/openless-all/app/src/styles/global.css index 3d975d52..bc085cc8 100644 --- a/openless-all/app/src/styles/global.css +++ b/openless-all/app/src/styles/global.css @@ -66,6 +66,13 @@ input, textarea { font-family: inherit; user-select: text; -webkit-user-select: text; + color: var(--ol-input-ink); + caret-color: var(--ol-input-ink); +} + +input::placeholder, +textarea::placeholder { + color: var(--ol-input-placeholder); } a { color: inherit; text-decoration: none; } @@ -145,14 +152,7 @@ a { color: inherit; text-decoration: none; } .ol-frost { position: relative; isolation: isolate; - /* 体渐变 + 左上高光扫面 —— 半透明白底(~0.9),给磨砂面体积感而不是死平。 */ - background: - radial-gradient(135% 86% at 15% -10%, - rgba(255, 255, 255, 0.97) 0%, - rgba(255, 255, 255, 0) 56%), - linear-gradient(161deg, - rgba(255, 255, 255, 0.93) 0%, - rgba(244, 247, 252, 0.88) 100%); + background: var(--ol-frost-bg); } .ol-frost::before { @@ -161,12 +161,12 @@ a { color: inherit; text-decoration: none; } inset: 0; z-index: -1; border-radius: inherit; - /* 不透明灰度噪点,normal 混合 —— 这是"磨砂颗粒"的来源。opacity 直接等于颗粒 - 强度:0.1 几乎看不见,0.32 能明显看出磨砂质感又不脏。要更糙往 0.45 调。 */ + /* 不透明灰度噪点,normal 混合 —— 这是"磨砂颗粒"的来源。opacity 由 + --ol-frost-grain-opacity 控制颗粒强度。 */ background-image: var(--ol-frost-grain); /* tile 与 SVG 视口同尺寸(100),stitchTiles 的无缝拼接才成立 */ background-size: 100px 100px; - opacity: 0.32; + opacity: var(--ol-frost-grain-opacity); pointer-events: none; } diff --git a/openless-all/app/src/styles/tokens.css b/openless-all/app/src/styles/tokens.css index a19679e6..d822cfec 100755 --- a/openless-all/app/src/styles/tokens.css +++ b/openless-all/app/src/styles/tokens.css @@ -56,6 +56,25 @@ /* Frost grain texture used by the .ol-frost helpers in global.css. */ --ol-frost-grain: url("data:image/svg+xml,%3Csvg%20xmlns='http://www.w3.org/2000/svg'%20width='100'%20height='100'%3E%3Cfilter%20id='g'%3E%3CfeTurbulence%20type='fractalNoise'%20baseFrequency='0.95'%20numOctaves='1'%20stitchTiles='stitch'/%3E%3CfeColorMatrix%20type='matrix'%20values='0.34%200.34%200.34%200%200%200.34%200.34%200.34%200%200%200.34%200.34%200.34%200%200%200%200%200%200%201'/%3E%3C/filter%3E%3Crect%20width='100'%20height='100'%20filter='url(%23g)'/%3E%3C/svg%3E"); + --ol-frost-bg: + radial-gradient(135% 86% at 15% -10%, + rgba(255, 255, 255, 0.97) 0%, + rgba(255, 255, 255, 0) 56%), + linear-gradient(161deg, + rgba(255, 255, 255, 0.93) 0%, + rgba(244, 247, 252, 0.88) 100%); + --ol-frost-grain-opacity: 0.32; + + /* Capsule circle buttons */ + --ol-capsule-cancel-bg: var(--ol-control-muted-strong); + --ol-capsule-cancel-ink: var(--ol-ink); + --ol-capsule-confirm-bg: var(--ol-control-solid); + --ol-capsule-confirm-ink: var(--ol-ink); + + /* Settings / form inputs */ + --ol-input-bg: var(--ol-surface-2); + --ol-input-ink: var(--ol-ink); + --ol-input-placeholder: var(--ol-placeholder); /* Motion */ --ol-motion-spring: cubic-bezier(0.16, 1, 0.3, 1); @@ -240,6 +259,24 @@ --ol-glass-border: rgba(255, 255, 255, 0.10); --ol-aura-glass-border: rgba(255, 255, 255, 0.10); + --ol-frost-bg: + radial-gradient(135% 86% at 15% -10%, + rgba(255, 255, 255, 0.10) 0%, + rgba(255, 255, 255, 0) 56%), + linear-gradient(161deg, + rgba(22, 28, 38, 0.88) 0%, + rgba(14, 18, 26, 0.82) 100%); + --ol-frost-grain-opacity: 0.28; + + --ol-capsule-cancel-bg: rgba(255, 255, 255, 0.10); + --ol-capsule-cancel-ink: var(--ol-ink-2); + --ol-capsule-confirm-bg: var(--ol-accent-solid-bg); + --ol-capsule-confirm-ink: var(--ol-accent-solid-ink); + + --ol-input-bg: rgba(255, 255, 255, 0.06); + --ol-input-ink: var(--ol-ink); + --ol-input-placeholder: var(--ol-ink-4); + --ol-shadow-sm: 0 1px 2px rgba(0, 0, 0, 0.34), 0 0 0 0.5px rgba(255, 255, 255, 0.04); --ol-shadow-md: 0 10px 30px -20px rgba(0, 0, 0, 0.42), 0 0 0 0.5px rgba(255, 255, 255, 0.05); --ol-shadow-lg: 0 24px 72px -32px rgba(0, 0, 0, 0.58), 0 0 0 0.5px rgba(255, 255, 255, 0.05);