统一卡片 UI 设计,优化主题切换与背景样式

This commit is contained in:
HKLHaoBin committed 2026-06-12 23:15:12 +08:00
1 parent 00fd21d78e
commit 012cb77cea
9 files changed
+212 -47

No files matched your search

@@ -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');
@@ -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(
+63 -19
View File
@@ -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<R: Runtime>(window: &tauri::WebviewWindow<R>) {
fn apply_windows_caption_theme<R: Runtime>(window: &tauri::WebviewWindow<R>, 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<T>(
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);
}
}
+2 -2
View File
@@ -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',
+18
View File
@@ -120,3 +120,21 @@ export async function getPlatformCapabilities(): Promise<PlatformCapabilities> {
export function getCachedPlatformCapabilities(): PlatformCapabilities | null {
return cachedCapabilities;
}
/** Win11 原生标题栏暗色同步;非 Windows / 非 Tauri 为 no-op。 */
export async function syncWindowsCaptionTheme(dark: boolean): Promise<void> {
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);
}
}
+12 -1
View File
@@ -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 {
@@ -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:
+11 -11
View File
@@ -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;
}
+37
View File
@@ -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);