Merge pull request #519 from Open-Less/feat/frosted-glass-overlays
feat(ui): 划词追问框与语音胶囊磨砂玻璃质感
This commit is contained in:
16 files changed
+187
-23
No files matched your search
@@ -474,6 +474,38 @@ async fn resolve_beta_manifest_endpoints() -> Result<Vec<url::Url>, String> {
|
||||
Ok(vec![mirror_url, direct_url])
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Serialize)]
|
||||
#[serde(rename_all = "camelCase")]
|
||||
pub struct NetworkCheckResult {
|
||||
pub online: bool,
|
||||
pub latency_ms: Option<u64>,
|
||||
}
|
||||
|
||||
#[tauri::command]
|
||||
pub async fn check_network() -> NetworkCheckResult {
|
||||
let client = reqwest::Client::builder()
|
||||
.timeout(std::time::Duration::from_secs(5))
|
||||
.build();
|
||||
let client = match client {
|
||||
Ok(c) => c,
|
||||
Err(_) => return NetworkCheckResult { online: false, latency_ms: None },
|
||||
};
|
||||
let start = std::time::Instant::now();
|
||||
let endpoints = [
|
||||
"https://apic.openless.top/health",
|
||||
"https://github.com",
|
||||
];
|
||||
for url in &endpoints {
|
||||
if let Ok(resp) = client.head(*url).send().await {
|
||||
if resp.status().is_success() || resp.status().is_redirection() {
|
||||
let ms = start.elapsed().as_millis() as u64;
|
||||
return NetworkCheckResult { online: true, latency_ms: Some(ms) };
|
||||
}
|
||||
}
|
||||
}
|
||||
NetworkCheckResult { online: false, latency_ms: None }
|
||||
}
|
||||
|
||||
#[tauri::command]
|
||||
pub fn get_hotkey_status(coord: CoordinatorState<'_>) -> HotkeyStatus {
|
||||
coord.hotkey_status()
|
||||
@@ -2812,7 +2844,10 @@ pub struct GithubDeviceStartResponse {
|
||||
#[tauri::command]
|
||||
pub async fn github_device_flow_start() -> Result<GithubDeviceStartResponse, String> {
|
||||
let client_id = get_github_oauth_client_id()?;
|
||||
let client = reqwest::Client::new();
|
||||
let client = reqwest::Client::builder()
|
||||
.timeout(std::time::Duration::from_secs(15))
|
||||
.build()
|
||||
.map_err(|e| format!("build http client: {e}"))?;
|
||||
let resp = client
|
||||
.post("https://github.com/login/device/code")
|
||||
.header("Accept", "application/json")
|
||||
@@ -2857,7 +2892,10 @@ pub async fn github_device_flow_poll(
|
||||
device_code: String,
|
||||
) -> Result<GithubDevicePollResult, String> {
|
||||
let client_id = get_github_oauth_client_id()?;
|
||||
let client = reqwest::Client::new();
|
||||
let client = reqwest::Client::builder()
|
||||
.timeout(std::time::Duration::from_secs(15))
|
||||
.build()
|
||||
.map_err(|e| format!("build http client: {e}"))?;
|
||||
let token_resp = client
|
||||
.post("https://github.com/login/oauth/access_token")
|
||||
.header("Accept", "application/json")
|
||||
|
||||
@@ -279,6 +279,7 @@ pub fn run() {
|
||||
commands::set_update_channel,
|
||||
commands::fetch_latest_beta_release,
|
||||
commands::app_check_update_with_channel,
|
||||
commands::check_network,
|
||||
commands::get_hotkey_status,
|
||||
commands::get_hotkey_capability,
|
||||
commands::set_shortcut_recording_active,
|
||||
|
||||
@@ -15,7 +15,7 @@ import { useTranslation } from 'react-i18next';
|
||||
import { isTauri, restartApp } from '../lib/ipc';
|
||||
import { Btn } from '../pages/_atoms';
|
||||
|
||||
const UPDATE_CHECK_TIMEOUT_MS = 8_000; // 缩短超时,让镜像站慢的情况能更快 fallback
|
||||
const UPDATE_CHECK_TIMEOUT_MS = 15_000;
|
||||
|
||||
interface AppUpdateMetadata {
|
||||
rid: number;
|
||||
@@ -44,6 +44,7 @@ export interface UseAutoUpdate {
|
||||
contentLength: number | null;
|
||||
checking: boolean;
|
||||
busy: boolean;
|
||||
errorMessage: string | null;
|
||||
/** 触发"检查更新"。如果发现新版本,状态变为 'available',需要 caller 渲染对话框让用户确认下载。 */
|
||||
checkForUpdates: () => Promise<void>;
|
||||
/** 用户在对话框里确认 → 下载 + 安装。完成后状态变为 'downloaded',等用户点重启。 */
|
||||
@@ -58,6 +59,7 @@ export function useAutoUpdate(): UseAutoUpdate {
|
||||
const [version, setVersion] = useState('');
|
||||
const [downloaded, setDownloaded] = useState(0);
|
||||
const [contentLength, setContentLength] = useState<number | null>(null);
|
||||
const [errorMessage, setErrorMessage] = useState<string | null>(null);
|
||||
|
||||
const checking = status === 'checking';
|
||||
const busy = status === 'downloading' || status === 'installing';
|
||||
@@ -89,6 +91,7 @@ export function useAutoUpdate(): UseAutoUpdate {
|
||||
const checkForUpdates = async () => {
|
||||
setStatus('checking');
|
||||
setVersion('');
|
||||
setErrorMessage(null);
|
||||
resetProgress();
|
||||
await closeUpdate();
|
||||
try {
|
||||
@@ -120,6 +123,8 @@ export function useAutoUpdate(): UseAutoUpdate {
|
||||
setStatus('available');
|
||||
} catch (error) {
|
||||
console.error('[updater] failed to check update', error);
|
||||
const msg = error instanceof Error ? error.message : String(error);
|
||||
setErrorMessage(msg);
|
||||
setStatus('error');
|
||||
}
|
||||
};
|
||||
@@ -146,6 +151,8 @@ export function useAutoUpdate(): UseAutoUpdate {
|
||||
setStatus('downloaded');
|
||||
} catch (error) {
|
||||
console.error('[updater] failed to install update', error);
|
||||
const msg = error instanceof Error ? error.message : String(error);
|
||||
setErrorMessage(msg);
|
||||
await closeUpdate();
|
||||
setStatus('error');
|
||||
}
|
||||
@@ -167,6 +174,7 @@ export function useAutoUpdate(): UseAutoUpdate {
|
||||
contentLength,
|
||||
checking,
|
||||
busy,
|
||||
errorMessage,
|
||||
checkForUpdates,
|
||||
installUpdate,
|
||||
dismissDialog,
|
||||
|
||||
@@ -244,10 +244,12 @@ function Pill({ os, state, level, insertedChars, message, onCancel, onConfirm }:
|
||||
const ambient = state === 'recording' ? Math.min(1, Math.max(0, level)) : 0;
|
||||
const scale = os === 'win' ? 1 : 1 + ambient * 0.018;
|
||||
const shadowAlpha = 0.20 + ambient * 0.10;
|
||||
const useBackdrop = true;
|
||||
|
||||
return (
|
||||
// 假毛玻璃:半透明白底 + .ol-frost 噪点纹理 + 内描边高光 + 柔和阴影。
|
||||
// 不写 backdrop-filter —— webview 模糊不了透明窗口背后的桌面(Tauri 上游限制)。
|
||||
<div
|
||||
className="ol-frost"
|
||||
style={{
|
||||
display: 'inline-flex',
|
||||
alignItems: 'center',
|
||||
@@ -258,13 +260,10 @@ function Pill({ os, state, level, insertedChars, message, onCancel, onConfirm }:
|
||||
height: metrics.height,
|
||||
boxSizing: metrics.boxSizing,
|
||||
borderRadius: 999,
|
||||
background: 'rgba(255, 255, 255, 0.85)',
|
||||
backdropFilter: useBackdrop ? 'blur(28px) saturate(180%)' : 'none',
|
||||
WebkitBackdropFilter: useBackdrop ? 'blur(28px) saturate(180%)' : 'none',
|
||||
border: '1px solid rgba(255, 255, 255, 0.55)',
|
||||
boxShadow: os === 'win'
|
||||
? `0 10px 24px -14px rgba(0, 0, 0, ${(0.24 + ambient * 0.06).toFixed(3)}), 0 0 0 0.5px rgba(0, 0, 0, 0.08), inset 0 0.5px 0 rgba(255, 255, 255, 0.55)`
|
||||
: `0 18px 50px -10px rgba(0, 0, 0, ${shadowAlpha.toFixed(3)}), 0 0 0 0.5px rgba(0, 0, 0, 0.08), inset 0 0.5px 0 rgba(255, 255, 255, 0.55)`,
|
||||
? `0 10px 24px -14px rgba(0, 0, 0, ${(0.24 + ambient * 0.06).toFixed(3)}), 0 0 0 0.5px rgba(0, 0, 0, 0.08), inset 0 1px 0 0 rgba(255, 255, 255, 0.8)`
|
||||
: `0 18px 50px -10px rgba(0, 0, 0, ${shadowAlpha.toFixed(3)}), 0 0 0 0.5px rgba(0, 0, 0, 0.08), inset 0 1px 0 0 rgba(255, 255, 255, 0.8)`,
|
||||
color: 'var(--ol-ink)',
|
||||
fontFamily: 'var(--ol-font-sans)',
|
||||
transform: `scale(${scale.toFixed(4)})`,
|
||||
|
||||
@@ -682,6 +682,7 @@ export const en: typeof zhCN = {
|
||||
networkLabel: 'Network',
|
||||
networkDesc: 'Required for cloud ASR / LLM calls. Disable for local-only mode.',
|
||||
networkOk: 'Available',
|
||||
networkOffline: 'Unavailable',
|
||||
checking: 'Checking…',
|
||||
granted: 'Granted',
|
||||
notApplicable: 'Not required',
|
||||
@@ -753,6 +754,7 @@ export const en: typeof zhCN = {
|
||||
checkingUpdate: 'Checking…',
|
||||
upToDate: 'You are already on the latest version.',
|
||||
updateError: 'Update check or install failed. Please try again later.',
|
||||
retryBtn: 'Retry',
|
||||
openReleases: 'Open Releases',
|
||||
source: 'Source',
|
||||
docs: 'Docs',
|
||||
|
||||
@@ -684,6 +684,7 @@ export const ja: typeof zhCN = {
|
||||
networkLabel: 'ネットワーク',
|
||||
networkDesc: 'クラウド ASR / LLM 呼び出しに必要。ローカルモードでは無効化可能。',
|
||||
networkOk: '利用可能',
|
||||
networkOffline: '利用不可',
|
||||
checking: '確認中…',
|
||||
granted: '許可済み',
|
||||
notApplicable: '権限不要',
|
||||
@@ -755,6 +756,7 @@ export const ja: typeof zhCN = {
|
||||
checkingUpdate: '確認中…',
|
||||
upToDate: '現在最新バージョンです。',
|
||||
updateError: '確認またはアップデートに失敗しました。後で再試行してください。',
|
||||
retryBtn: '再試行',
|
||||
openReleases: 'Releases を開く',
|
||||
source: 'ソース',
|
||||
docs: 'ドキュメント',
|
||||
|
||||
@@ -684,6 +684,7 @@ export const ko: typeof zhCN = {
|
||||
networkLabel: '네트워크',
|
||||
networkDesc: '클라우드 ASR / LLM 호출에 필요. 로컬 모드에서는 비활성화 가능.',
|
||||
networkOk: '사용 가능',
|
||||
networkOffline: '사용 불가',
|
||||
checking: '확인 중…',
|
||||
granted: '허용됨',
|
||||
notApplicable: '권한 불필요',
|
||||
@@ -755,6 +756,7 @@ export const ko: typeof zhCN = {
|
||||
checkingUpdate: '확인 중…',
|
||||
upToDate: '현재 최신 버전입니다.',
|
||||
updateError: '확인 또는 업데이트에 실패했습니다. 잠시 후 다시 시도하세요.',
|
||||
retryBtn: '다시 시도',
|
||||
openReleases: 'Releases 열기',
|
||||
source: '소스',
|
||||
docs: '문서',
|
||||
|
||||
@@ -680,6 +680,7 @@ export const zhCN = {
|
||||
networkLabel: '网络',
|
||||
networkDesc: '云端 ASR / LLM 必需,本地模式可关。',
|
||||
networkOk: '可用',
|
||||
networkOffline: '不可用',
|
||||
checking: '检查中…',
|
||||
granted: '已授权',
|
||||
notApplicable: '无需授权',
|
||||
@@ -751,6 +752,7 @@ export const zhCN = {
|
||||
checkingUpdate: '检查中…',
|
||||
upToDate: '当前已是最新版本。',
|
||||
updateError: '检查或更新失败,请稍后重试。',
|
||||
retryBtn: '重试',
|
||||
openReleases: '打开 Releases',
|
||||
source: '源码',
|
||||
docs: '文档',
|
||||
|
||||
@@ -682,6 +682,7 @@ export const zhTW: typeof zhCN = {
|
||||
networkLabel: '網絡',
|
||||
networkDesc: '雲端 ASR / LLM 調用所必需。本地模式可關閉。',
|
||||
networkOk: '可用',
|
||||
networkOffline: '不可用',
|
||||
checking: '檢查中…',
|
||||
granted: '已授權',
|
||||
notApplicable: '無需授權',
|
||||
@@ -753,6 +754,7 @@ export const zhTW: typeof zhCN = {
|
||||
checkingUpdate: '檢查中…',
|
||||
upToDate: '當前已是最新版本。',
|
||||
updateError: '檢查或更新失敗,請稍後重試。',
|
||||
retryBtn: '重試',
|
||||
openReleases: '打開 Releases',
|
||||
source: '源碼',
|
||||
docs: '文檔',
|
||||
|
||||
@@ -511,6 +511,18 @@ export function getWindowsImeStatus(): Promise<WindowsImeStatus> {
|
||||
return invokeOrMock('get_windows_ime_status', undefined, () => mockWindowsImeStatus);
|
||||
}
|
||||
|
||||
export interface NetworkCheckResult {
|
||||
online: boolean;
|
||||
latencyMs: number | null;
|
||||
}
|
||||
|
||||
export function checkNetwork(): Promise<NetworkCheckResult> {
|
||||
return invokeOrMock<NetworkCheckResult>('check_network', undefined, () => ({
|
||||
online: true,
|
||||
latencyMs: 42,
|
||||
}));
|
||||
}
|
||||
|
||||
export function listMicrophoneDevices(): Promise<MicrophoneDevice[]> {
|
||||
return invokeOrMock('list_microphone_devices', undefined, () => mockMicrophoneDevices);
|
||||
}
|
||||
|
||||
@@ -580,8 +580,13 @@ export function Marketplace() {
|
||||
|
||||
{loadError && (
|
||||
<Card padding={16} style={{ marginBottom: 12, borderColor: 'var(--ol-err)' }}>
|
||||
<div style={{ fontSize: 12, color: 'var(--ol-err)' }}>
|
||||
{t('marketplace.loadFailed', { err: loadError })}
|
||||
<div style={{ display: 'flex', alignItems: 'center', justifyContent: 'space-between', gap: 10 }}>
|
||||
<div style={{ fontSize: 12, color: 'var(--ol-err)', flex: 1, wordBreak: 'break-word' }}>
|
||||
{t('marketplace.loadFailed', { err: loadError })}
|
||||
</div>
|
||||
<Btn variant="blue" size="sm" onClick={() => void refresh()}>
|
||||
{t('common.retry') ?? '重试'}
|
||||
</Btn>
|
||||
</div>
|
||||
</Card>
|
||||
)}
|
||||
|
||||
@@ -201,7 +201,7 @@ export function QaPanel() {
|
||||
}, [messages, status]);
|
||||
|
||||
return (
|
||||
<div style={shellStyle}>
|
||||
<div className="ol-frost" style={shellStyle}>
|
||||
<Toolbar pinned={pinned} onTogglePin={onTogglePin} onClose={onClose} />
|
||||
<div ref={scrollRef} style={contentStyle}>
|
||||
{messages.length === 0 && status === 'idle' && (
|
||||
@@ -611,6 +611,9 @@ function truncate(text: string, max: number): string {
|
||||
|
||||
// ── 样式 ──────────────────────────────────────────────────────────────
|
||||
|
||||
// 假毛玻璃外壳:玻璃质感(体渐变 + 高光扫面 + 噪点颗粒)全部由 .ol-frost 提供;
|
||||
// 这里只管布局 + 内描边高光 + 柔和阴影。webview 模糊不了透明窗口背后的桌面
|
||||
// (Tauri 上游限制),所以不写 background / backdrop-filter。
|
||||
const shellStyle: CSSProperties = {
|
||||
width: '100%',
|
||||
height: '100vh',
|
||||
@@ -618,13 +621,8 @@ const shellStyle: CSSProperties = {
|
||||
flexDirection: 'column',
|
||||
borderRadius: 14,
|
||||
overflow: 'hidden',
|
||||
// 浮窗 focus:false 在 macOS 上会让 backdrop-filter 不工作(透到桌面文字),所以
|
||||
// 改成接近不透明的实色背景。blur 仅作锦上添花,不再依赖它保证可读性。
|
||||
background: 'rgba(255, 255, 255, 0.97)',
|
||||
backdropFilter: 'blur(24px) saturate(180%)',
|
||||
WebkitBackdropFilter: 'blur(24px) saturate(180%)',
|
||||
border: '0.5px solid rgba(0, 0, 0, 0.08)',
|
||||
boxShadow: 'var(--ol-shadow-lg)',
|
||||
boxShadow: 'var(--ol-shadow-lg), inset 0 1px 0 0 rgba(255, 255, 255, 0.9)',
|
||||
fontFamily: 'var(--ol-font-sans)',
|
||||
color: 'var(--ol-ink)',
|
||||
};
|
||||
|
||||
@@ -17,9 +17,24 @@ export function AboutUpdateControl({ tagline }: { tagline: string }) {
|
||||
{u.checking ? t('settings.about.checkingUpdate') : t('settings.about.checkUpdateBtn')}
|
||||
</Btn>
|
||||
</div>
|
||||
{(u.status === 'none' || u.status === 'error') && (
|
||||
<div style={{ fontSize: 11, color: u.status === 'error' ? 'var(--ol-err)' : 'var(--ol-ink-4)', marginTop: 4 }}>
|
||||
{u.status === 'none' ? t('settings.about.upToDate') : t('settings.about.updateError')}
|
||||
{u.status === 'none' && (
|
||||
<div style={{ fontSize: 11, color: 'var(--ol-ink-4)', marginTop: 4 }}>
|
||||
{t('settings.about.upToDate')}
|
||||
</div>
|
||||
)}
|
||||
{u.status === 'error' && (
|
||||
<div style={{ marginTop: 4 }}>
|
||||
<div style={{ fontSize: 11, color: 'var(--ol-err)' }}>
|
||||
{t('settings.about.updateError')}
|
||||
</div>
|
||||
{u.errorMessage && (
|
||||
<div style={{ fontSize: 10.5, color: 'var(--ol-ink-4)', marginTop: 2, wordBreak: 'break-word' }}>
|
||||
{u.errorMessage}
|
||||
</div>
|
||||
)}
|
||||
<Btn variant="ghost" size="sm" onClick={u.checkForUpdates} style={{ marginTop: 4 }}>
|
||||
{t('settings.about.retryBtn') ?? t('common.retry') ?? '重试'}
|
||||
</Btn>
|
||||
</div>
|
||||
)}
|
||||
{isDialogStatus(u.status) && (
|
||||
|
||||
@@ -7,12 +7,14 @@ import { Icon } from '../../components/Icon';
|
||||
import {
|
||||
checkAccessibilityPermission,
|
||||
checkMicrophonePermission,
|
||||
checkNetwork,
|
||||
getHotkeyStatus,
|
||||
getWindowsImeStatus,
|
||||
openSystemSettings,
|
||||
requestAccessibilityPermission,
|
||||
requestMicrophonePermission,
|
||||
} from '../../lib/ipc';
|
||||
import type { NetworkCheckResult } from '../../lib/ipc';
|
||||
import type {
|
||||
HotkeyCapability,
|
||||
HotkeyStatus,
|
||||
@@ -30,6 +32,7 @@ export function PermissionsSection() {
|
||||
const [microphone, setMicrophone] = useState<PermissionStatus | 'loading'>('loading');
|
||||
const [hotkey, setHotkey] = useState<HotkeyStatus | null>(null);
|
||||
const [windowsIme, setWindowsIme] = useState<WindowsImeStatus | null>(null);
|
||||
const [network, setNetwork] = useState<NetworkCheckResult | null>(null);
|
||||
const { capability } = useHotkeySettings();
|
||||
|
||||
const refreshPermissions = async () => {
|
||||
@@ -49,22 +52,34 @@ export function PermissionsSection() {
|
||||
setWindowsIme(await getWindowsImeStatus());
|
||||
};
|
||||
|
||||
const refreshNetwork = async () => {
|
||||
try {
|
||||
setNetwork(await checkNetwork());
|
||||
} catch {
|
||||
setNetwork({ online: false, latencyMs: null });
|
||||
}
|
||||
};
|
||||
|
||||
useEffect(() => {
|
||||
refreshPermissions();
|
||||
refreshHotkey();
|
||||
refreshWindowsIme();
|
||||
refreshNetwork();
|
||||
const hotkeyId = window.setInterval(refreshHotkey, 1000);
|
||||
// 麦克风检查会短暂打开输入流,避免每秒探测导致隐私指示器频繁闪烁。
|
||||
const permissionId = window.setInterval(refreshPermissions, 10000);
|
||||
const networkId = window.setInterval(refreshNetwork, 30000);
|
||||
const onFocus = () => {
|
||||
refreshPermissions();
|
||||
refreshHotkey();
|
||||
refreshWindowsIme();
|
||||
refreshNetwork();
|
||||
};
|
||||
window.addEventListener('focus', onFocus);
|
||||
return () => {
|
||||
window.clearInterval(hotkeyId);
|
||||
window.clearInterval(permissionId);
|
||||
window.clearInterval(networkId);
|
||||
window.removeEventListener('focus', onFocus);
|
||||
};
|
||||
}, []);
|
||||
@@ -157,8 +172,18 @@ export function PermissionsSection() {
|
||||
</SettingRow>
|
||||
)}
|
||||
<SettingRow label={t('settings.permissions.networkLabel')} desc={t('settings.permissions.networkDesc')}>
|
||||
<div style={{ display: 'flex', justifyContent: 'flex-end', width: '100%' }}>
|
||||
<Pill tone="ok"><Icon name="check" size={11} />{t('settings.permissions.networkOk')}</Pill>
|
||||
<div style={{ display: 'flex', gap: 8, alignItems: 'center', justifyContent: 'flex-end', width: '100%' }}>
|
||||
{network && network.latencyMs != null && (
|
||||
<span style={{ fontSize: 11, color: 'var(--ol-ink-4)' }}>
|
||||
{network.latencyMs}ms
|
||||
</span>
|
||||
)}
|
||||
<NetworkStatusPill status={network} />
|
||||
{network && !network.online && (
|
||||
<Btn variant="ghost" size="sm" onClick={refreshNetwork}>
|
||||
{t('common.retry') ?? '重试'}
|
||||
</Btn>
|
||||
)}
|
||||
</div>
|
||||
</SettingRow>
|
||||
</Card>
|
||||
@@ -207,6 +232,17 @@ function WindowsImeStatusPill({ status }: { status: WindowsImeStatus | null }) {
|
||||
return <Pill tone="outline">{t('settings.permissions.windowsImeUnavailable')}</Pill>;
|
||||
}
|
||||
|
||||
function NetworkStatusPill({ status }: { status: NetworkCheckResult | null }) {
|
||||
const { t } = useTranslation();
|
||||
if (!status) {
|
||||
return <Pill tone="default">{t('settings.permissions.checking')}</Pill>;
|
||||
}
|
||||
if (status.online) {
|
||||
return <Pill tone="ok"><Icon name="check" size={11} />{t('settings.permissions.networkOk')}</Pill>;
|
||||
}
|
||||
return <Pill tone="outline">{t('settings.permissions.networkOffline') ?? '不可用'}</Pill>;
|
||||
}
|
||||
|
||||
function adapterDisplayName(adapter: HotkeyCapability['adapter'] | HotkeyStatus['adapter']) {
|
||||
if (adapter === 'macEventTap') return i18n.t('hotkey.adapter.macEventTap');
|
||||
if (adapter === 'windowsLowLevel') return i18n.t('hotkey.adapter.windowsLowLevel');
|
||||
|
||||
@@ -109,3 +109,38 @@ a { color: inherit; text-decoration: none; }
|
||||
}
|
||||
}
|
||||
|
||||
/* ── 假毛玻璃面(qa 追问框 / capsule 语音胶囊等透明浮窗)─────────────────────
|
||||
webview 拿不到透明窗口背后的桌面像素 —— CSS backdrop-filter 模糊不了桌面,
|
||||
是 Tauri 已知的上游限制(issue #2827 / #10064 / #6876)。所以这里**不做
|
||||
真实高斯模糊**:宿主元素保留半透明白底,这层 ::before 叠一张细颗粒噪点纹理
|
||||
(磨砂质感来源);内描边高光与柔和阴影由宿主自带的 box-shadow 提供。
|
||||
纯静态 CSS,零运行时 / 动画开销,macOS / Windows / Linux 像素一致。
|
||||
宿主元素自带 border-radius 即可,::before 噪点层 inherit 圆角;宿主不要再设
|
||||
backdrop-filter(死代码)。 */
|
||||
.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%);
|
||||
}
|
||||
|
||||
.ol-frost::before {
|
||||
content: '';
|
||||
position: absolute;
|
||||
inset: 0;
|
||||
z-index: -1;
|
||||
border-radius: inherit;
|
||||
/* 不透明灰度噪点,normal 混合 —— 这是"磨砂颗粒"的来源。opacity 直接等于颗粒
|
||||
强度:0.1 几乎看不见,0.32 能明显看出磨砂质感又不脏。要更糙往 0.45 调。 */
|
||||
background-image: var(--ol-frost-grain);
|
||||
/* tile 与 SVG 视口同尺寸(100),stitchTiles 的无缝拼接才成立 */
|
||||
background-size: 100px 100px;
|
||||
opacity: 0.32;
|
||||
pointer-events: none;
|
||||
}
|
||||
@@ -30,6 +30,13 @@
|
||||
--ol-glass-blur: 20px;
|
||||
--ol-glass-blur-strong: 36px;
|
||||
|
||||
/* 假毛玻璃噪点 —— qa 追问框 / capsule 语音胶囊等透明浮窗用。详见 global.css .ol-frost。
|
||||
关键点:feColorMatrix 用 matrix 把分形噪点**去色 + 强制 alpha=1**(不透明灰度噪点)。
|
||||
早期版本用 saturate=0 保留了 feTurbulence 的随机 alpha,噪点本身就半透明,再叠
|
||||
::before 的低 opacity,等于洗没了 —— 看不出磨砂颗粒。现在用不透明灰度噪点,由
|
||||
::before 的 opacity 单独、可控地决定颗粒强度。 */
|
||||
--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");
|
||||
|
||||
/* Motion */
|
||||
--ol-motion-spring: cubic-bezier(0.16, 1, 0.3, 1);
|
||||
--ol-motion-soft: cubic-bezier(0.22, 0.8, 0.22, 1);
|
||||
|
||||
Reference in new issue
Block a user