diff --git a/.gitignore b/.gitignore
index de335f6c..8b34ae3d 100644
--- a/.gitignore
+++ b/.gitignore
@@ -25,6 +25,7 @@ promo-openless/
promo-openless-v2/
docs/old-promo/
.worktrees/
+.artifacts/
# 宣传录屏 / 素材根目录(GB 级二进制,绝不入版本库)
video-materials/
diff --git a/openless-all/app/scripts/windows-capsule-contract.test.mjs b/openless-all/app/scripts/windows-capsule-contract.test.mjs
new file mode 100644
index 00000000..17d8968f
--- /dev/null
+++ b/openless-all/app/scripts/windows-capsule-contract.test.mjs
@@ -0,0 +1,104 @@
+import { readFile } from 'node:fs/promises';
+
+function assertMatch(source, pattern, name) {
+ if (!pattern.test(source)) {
+ throw new Error(`${name}: pattern ${pattern} not found`);
+ }
+}
+
+function assertNotMatch(source, pattern, name) {
+ if (pattern.test(source)) {
+ throw new Error(`${name}: forbidden pattern ${pattern} found`);
+ }
+}
+
+const libRs = await readFile(new URL('../src-tauri/src/lib.rs', import.meta.url), 'utf-8');
+const coordinatorRs = await readFile(new URL('../src-tauri/src/coordinator.rs', import.meta.url), 'utf-8');
+const dictationRs = await readFile(new URL('../src-tauri/src/coordinator/dictation.rs', import.meta.url), 'utf-8');
+const capsuleTsx = await readFile(new URL('../src/components/Capsule.tsx', import.meta.url), 'utf-8');
+const capsuleLayoutTs = await readFile(new URL('../src/lib/capsuleLayout.ts', import.meta.url), 'utf-8');
+const hitlRunner = await readFile(new URL('./windows-capsule-hitl.ps1', import.meta.url), 'utf-8');
+
+assertNotMatch(
+ libRs,
+ /apply_acrylic\(&capsule|DwmEnableBlurBehindWindow|DWMWA_SYSTEMBACKDROP_TYPE/,
+ 'windows capsule native host must not use Acrylic/Mica/DWM material',
+);
+
+assertNotMatch(
+ libRs,
+ /WS_EX_TRANSPARENT|SetWindowLongPtrW|EnumChildWindows/,
+ 'windows capsule must not rely on dynamic WebView child HWND click-through styles',
+);
+
+assertMatch(
+ libRs,
+ /fn configure_windows_capsule_container_region[\s\S]*?GetWindowRect[\s\S]*?windows_capsule_create_region[\s\S]*?SetWindowRgn/,
+ 'windows capsule should apply a native container/input envelope',
+);
+
+assertMatch(
+ libRs,
+ /fn windows_capsule_region_rects[\s\S]*?windows_capsule_translation_badge_region_rect[\s\S]*?windows_capsule_pill_region_rect/,
+ 'windows capsule native envelope should be derived from rendered capsule surfaces',
+);
+
+assertMatch(
+ libRs,
+ /CreateRoundRectRgn[\s\S]*?CombineRgn[\s\S]*?RGN_OR/,
+ 'windows capsule should use rounded native regions for pill and translation badge envelopes',
+);
+
+assertMatch(
+ libRs,
+ /fn windows_capsule_pill_region_rect[\s\S]*?const PILL_WIDTH: f64 = 196\.0;[\s\S]*?const PILL_HEIGHT: f64 = 52\.0;[\s\S]*?const BOTTOM_INSET: f64 = 12\.0;/,
+ 'windows native envelope should cover the full rendered capsule, not a strict inner CSS box',
+);
+
+assertMatch(
+ coordinatorRs,
+ /show_capsule_window_for_recording\(&app_for_main,\s*&window\);[\s\S]*?refresh_windows_capsule_container_region\(&window,\s*translation\)/,
+ 'windows capsule should refresh the native envelope after no-activate show',
+);
+
+assertMatch(
+ coordinatorRs,
+ /fn capsule_hitl_translation_fixture_enabled\(\)[\s\S]*?OPENLESS_CAPSULE_HITL_TRANSLATION/,
+ 'translation badge HITL fixture should be gated behind an explicit debug environment variable',
+);
+
+assertMatch(
+ dictationRs,
+ /if hotkey_injection_dry_run_enabled\(\)[\s\S]*?capsule_hitl_translation_fixture_enabled\(\)[\s\S]*?translation_modifier_seen[\s\S]*?store\(true,[\s\S]*?emit_capsule\(inner,\s*CapsuleState::Recording/,
+ 'translation badge HITL fixture should only affect hotkey-injection dry-run sessions',
+);
+
+assertMatch(
+ capsuleLayoutTs,
+ /export interface CapsuleInputEnvelopeMetrics[\s\S]*?export function getCapsuleInputEnvelopeMetrics/,
+ 'frontend layout contract should name native input envelope metrics separately from host and pill metrics',
+);
+
+assertMatch(
+ capsuleLayoutTs,
+ /getCapsuleInputEnvelopeMetrics\(os: OS\)[\s\S]*?width: 196,[\s\S]*?height: 52,[\s\S]*?radius: 26,[\s\S]*?bottomInset: 12,[\s\S]*?badgeWidth: 132,[\s\S]*?badgeHeight: 24,[\s\S]*?badgeGap: 8/,
+ 'frontend Windows input envelope metrics should match the native region contract',
+);
+
+assertMatch(
+ capsuleTsx,
+ /const useBackdrop = os !== 'win';[\s\S]*?background: os === 'win' \? 'rgba\(255, 255, 255, 0\.96\)' : 'rgba\(255, 255, 255, 0\.85\)'/,
+ 'windows capsule DOM pill should own its surface without relying on desktop backdrop blur',
+);
+
+assertMatch(
+ capsuleTsx,
+ /return\s*\(\s*
bool {
std::env::var_os("OPENLESS_HOTKEY_INJECTION_DRY_RUN").is_some()
}
+#[cfg(any(debug_assertions, test))]
+fn capsule_hitl_translation_fixture_enabled() -> bool {
+ std::env::var("OPENLESS_CAPSULE_HITL_TRANSLATION")
+ .ok()
+ .as_deref()
+ == Some("1")
+}
+
#[cfg(any(debug_assertions, test))]
fn debug_transcript_override_text() -> Option
{
let path = std::env::var_os("OPENLESS_DEBUG_TRANSCRIPT_FILE")?;
@@ -4412,6 +4420,8 @@ fn emit_capsule(
);
}
show_capsule_window_for_recording(&app_for_main, &window);
+ #[cfg(target_os = "windows")]
+ crate::refresh_windows_capsule_container_region(&window, translation);
// macOS/Windows 优先走 no-activate show,避免录音胶囊抢走当前工作 app 焦点。
// 若 fallback 到 show(),OpenLess 已是前台 app 时再把 key window 还给 main。
#[cfg(target_os = "macos")]
diff --git a/openless-all/app/src-tauri/src/coordinator/dictation.rs b/openless-all/app/src-tauri/src/coordinator/dictation.rs
index 56e661b1..8c784a77 100644
--- a/openless-all/app/src-tauri/src/coordinator/dictation.rs
+++ b/openless-all/app/src-tauri/src/coordinator/dictation.rs
@@ -486,6 +486,11 @@ pub(super) async fn begin_session(inner: &Arc) -> Result<(), String> {
#[cfg(any(debug_assertions, test))]
if hotkey_injection_dry_run_enabled() {
+ if capsule_hitl_translation_fixture_enabled() {
+ inner
+ .translation_modifier_seen
+ .store(true, Ordering::SeqCst);
+ }
emit_capsule(inner, CapsuleState::Recording, 0.0, 0, None, None);
inner.state.lock().phase = SessionPhase::Listening;
log::info!("[coord] session started (hotkey-injection dry-run)");
diff --git a/openless-all/app/src-tauri/src/lib.rs b/openless-all/app/src-tauri/src/lib.rs
index 4cee867a..771cee6c 100644
--- a/openless-all/app/src-tauri/src/lib.rs
+++ b/openless-all/app/src-tauri/src/lib.rs
@@ -133,15 +133,12 @@ pub fn run() {
// backdrop-filter 只能模糊 DOM 内部,模糊不到 OS 桌面 → 胶囊背景
// 看起来就是「透到桌面」。这里给 Win10/Win11 都支持的 Acrylic 做兜底,
// 让 OS 提供毛玻璃材质,胶囊 rgba(255,255,255,0.85) 上面再叠 DOM 模糊。
- // 失败不阻塞(老 Win10 / Win7 上 Acrylic 不可用),仅 warn。
+ // Windows capsule contract: the native HWND is only a transparent
+ // carrier/input envelope. The DOM pill owns the visible surface;
+ // Acrylic/Mica/DWM materials on this host paint the transparent
+ // carrier as a gray rectangle.
#[cfg(target_os = "windows")]
- {
- use window_vibrancy::apply_acrylic;
- // 中性偏冷的浅灰半透,与胶囊白底叠合后保持可读性。
- if let Err(e) = apply_acrylic(&capsule, Some((30, 32, 38, 140))) {
- log::warn!("[capsule] acrylic failed: {e}");
- }
- }
+ configure_windows_capsule_container_region(&capsule, false);
let _ = capsule.hide();
}
@@ -726,6 +723,205 @@ fn apply_windows_caption_color(window: &tauri::WebviewWindow) {
}
}
+#[cfg(target_os = "windows")]
+#[derive(Clone, Copy, Debug)]
+struct WindowsCapsuleRegionRect {
+ x: f64,
+ y: f64,
+ width: f64,
+ height: f64,
+ radius: f64,
+}
+
+#[cfg(target_os = "windows")]
+fn windows_capsule_pill_region_rect(bounds: CapsuleWindowBounds) -> WindowsCapsuleRegionRect {
+ const PILL_WIDTH: f64 = 196.0;
+ const PILL_HEIGHT: f64 = 52.0;
+ const BOTTOM_INSET: f64 = 12.0;
+
+ WindowsCapsuleRegionRect {
+ x: (bounds.width - PILL_WIDTH) / 2.0,
+ y: bounds.height - BOTTOM_INSET - PILL_HEIGHT,
+ width: PILL_WIDTH,
+ height: PILL_HEIGHT,
+ radius: PILL_HEIGHT / 2.0,
+ }
+}
+
+#[cfg(target_os = "windows")]
+fn windows_capsule_translation_badge_region_rect(
+ bounds: CapsuleWindowBounds,
+) -> WindowsCapsuleRegionRect {
+ const BADGE_WIDTH: f64 = 132.0;
+ const BADGE_HEIGHT: f64 = 24.0;
+ const BADGE_GAP: f64 = 8.0;
+
+ let pill = windows_capsule_pill_region_rect(bounds);
+ WindowsCapsuleRegionRect {
+ x: (bounds.width - BADGE_WIDTH) / 2.0,
+ y: (pill.y - BADGE_GAP - BADGE_HEIGHT).max(0.0),
+ width: BADGE_WIDTH,
+ height: BADGE_HEIGHT,
+ radius: BADGE_HEIGHT / 2.0,
+ }
+}
+
+#[cfg(target_os = "windows")]
+fn windows_capsule_region_rects(
+ bounds: CapsuleWindowBounds,
+ translation_active: bool,
+) -> Vec {
+ let mut rects = Vec::with_capacity(if translation_active { 2 } else { 1 });
+ if translation_active {
+ rects.push(windows_capsule_translation_badge_region_rect(bounds));
+ }
+ rects.push(windows_capsule_pill_region_rect(bounds));
+ rects
+}
+
+#[cfg(all(target_os = "windows", test))]
+fn windows_capsule_rounded_rect_contains_point(
+ rect: WindowsCapsuleRegionRect,
+ x: f64,
+ y: f64,
+) -> bool {
+ if x < rect.x || x > rect.x + rect.width || y < rect.y || y > rect.y + rect.height {
+ return false;
+ }
+
+ let radius = rect.radius.min(rect.width / 2.0).min(rect.height / 2.0);
+ let nearest_x = if x < rect.x + radius {
+ rect.x + radius
+ } else if x > rect.x + rect.width - radius {
+ rect.x + rect.width - radius
+ } else {
+ x
+ };
+ let nearest_y = if y < rect.y + radius {
+ rect.y + radius
+ } else if y > rect.y + rect.height - radius {
+ rect.y + rect.height - radius
+ } else {
+ y
+ };
+ let dx = x - nearest_x;
+ let dy = y - nearest_y;
+ dx * dx + dy * dy <= radius * radius
+}
+
+#[cfg(all(target_os = "windows", test))]
+fn windows_capsule_region_contains_point(
+ bounds: CapsuleWindowBounds,
+ translation_active: bool,
+ x: f64,
+ y: f64,
+) -> bool {
+ windows_capsule_region_rects(bounds, translation_active)
+ .into_iter()
+ .any(|rect| windows_capsule_rounded_rect_contains_point(rect, x, y))
+}
+
+#[cfg(target_os = "windows")]
+fn windows_capsule_region_px(value: f64, scale: f64, outward: bool) -> i32 {
+ let scaled = value * scale;
+ if outward {
+ scaled.ceil() as i32
+ } else {
+ scaled.floor() as i32
+ }
+}
+
+#[cfg(target_os = "windows")]
+fn windows_capsule_create_region(
+ bounds: CapsuleWindowBounds,
+ translation_active: bool,
+ scale_x: f64,
+ scale_y: f64,
+) -> windows::Win32::Graphics::Gdi::HRGN {
+ use windows::Win32::Graphics::Gdi::{
+ CombineRgn, CreateRoundRectRgn, DeleteObject, HRGN, RGN_ERROR, RGN_OR,
+ };
+
+ let mut combined = HRGN::default();
+ for rect in windows_capsule_region_rects(bounds, translation_active) {
+ let x1 = windows_capsule_region_px(rect.x, scale_x, false);
+ let y1 = windows_capsule_region_px(rect.y, scale_y, false);
+ let x2 = windows_capsule_region_px(rect.x + rect.width, scale_x, true);
+ let y2 = windows_capsule_region_px(rect.y + rect.height, scale_y, true);
+ let round_w = ((rect.radius * 2.0 * scale_x).round() as i32).max(1);
+ let round_h = ((rect.radius * 2.0 * scale_y).round() as i32).max(1);
+ let region = unsafe { CreateRoundRectRgn(x1, y1, x2, y2, round_w, round_h) };
+ if region.is_invalid() {
+ log::warn!("[capsule] create rounded container region failed");
+ continue;
+ }
+
+ if combined.is_invalid() {
+ combined = region;
+ } else {
+ let result = unsafe { CombineRgn(combined, combined, region, RGN_OR) };
+ let _ = unsafe { DeleteObject(region) };
+ if result == RGN_ERROR {
+ log::warn!("[capsule] combine container regions failed");
+ let _ = unsafe { DeleteObject(combined) };
+ return HRGN::default();
+ }
+ }
+ }
+ combined
+}
+
+#[cfg(target_os = "windows")]
+fn configure_windows_capsule_container_region(
+ window: &tauri::WebviewWindow,
+ translation_active: bool,
+) {
+ use raw_window_handle::{HasWindowHandle, RawWindowHandle};
+ use windows::Win32::Foundation::{BOOL, HWND, RECT};
+ use windows::Win32::Graphics::Gdi::{DeleteObject, SetWindowRgn};
+ use windows::Win32::UI::WindowsAndMessaging::GetWindowRect;
+
+ let handle = match window.window_handle().map(|h| h.as_raw()) {
+ Ok(RawWindowHandle::Win32(handle)) => handle,
+ Ok(other) => {
+ log::warn!("[capsule] unexpected raw window handle for container region: {other:?}");
+ return;
+ }
+ Err(e) => {
+ log::warn!("[capsule] read raw window handle for container region failed: {e}");
+ return;
+ }
+ };
+ let hwnd = HWND(handle.hwnd.get() as *mut core::ffi::c_void);
+ let bounds = capsule_window_bounds(translation_active);
+ let mut rect = RECT::default();
+ let (scale_x, scale_y) = if unsafe { GetWindowRect(hwnd, &mut rect) }.is_ok() {
+ let width = (rect.right - rect.left).max(1) as f64;
+ let height = (rect.bottom - rect.top).max(1) as f64;
+ (width / bounds.width.max(1.0), height / bounds.height.max(1.0))
+ } else {
+ (1.0, 1.0)
+ };
+
+ let region = windows_capsule_create_region(bounds, translation_active, scale_x, scale_y);
+ if region.is_invalid() {
+ return;
+ }
+
+ if unsafe { SetWindowRgn(hwnd, region, BOOL(1)) } == 0 {
+ let _ = unsafe { DeleteObject(region) };
+ log::warn!("[capsule] apply container region failed");
+ }
+}
+
+#[cfg(target_os = "windows")]
+pub(crate) fn refresh_windows_capsule_container_region(
+ window: &tauri::WebviewWindow,
+ translation_active: bool,
+) {
+ configure_windows_capsule_container_region(window, translation_active);
+}
+
#[tauri::command]
fn restart_app(app: AppHandle) {
// macOS:自动更新会让新装的 .app 带 com.apple.quarantine(无论 Tauri updater
@@ -1201,6 +1397,8 @@ pub(crate) fn position_capsule_bottom_center(
};
let bounds = capsule_window_bounds(translation_active);
window.set_size(LogicalSize::new(bounds.width, bounds.height))?;
+ #[cfg(target_os = "windows")]
+ configure_windows_capsule_container_region(window, translation_active);
let scale = monitor.scale_factor();
let size = monitor.size();
@@ -1363,6 +1561,31 @@ mod tests {
assert_eq!(capsule_visual_height(true), 96.0);
}
+ #[test]
+ #[cfg(target_os = "windows")]
+ fn capsule_container_region_consumes_only_rendered_pill_envelope() {
+ let bounds = capsule_window_bounds(false);
+
+ assert!(!super::windows_capsule_region_contains_point(bounds, false, 4.0, 4.0));
+ assert!(!super::windows_capsule_region_contains_point(bounds, false, 4.0, 50.0));
+ assert!(super::windows_capsule_region_contains_point(bounds, false, 110.0, 50.0));
+ assert!(super::windows_capsule_region_contains_point(bounds, false, 20.0, 50.0));
+ assert!(super::windows_capsule_region_contains_point(bounds, false, 200.0, 50.0));
+ assert!(!super::windows_capsule_region_contains_point(bounds, false, 10.0, 50.0));
+ assert!(!super::windows_capsule_region_contains_point(bounds, false, 110.0, 14.0));
+ }
+
+ #[test]
+ #[cfg(target_os = "windows")]
+ fn capsule_container_region_tracks_translation_badge_and_pill() {
+ let bounds = capsule_window_bounds(true);
+
+ assert!(super::windows_capsule_region_contains_point(bounds, true, 110.0, 46.0));
+ assert!(super::windows_capsule_region_contains_point(bounds, true, 110.0, 84.0));
+ assert!(!super::windows_capsule_region_contains_point(bounds, true, 16.0, 84.0));
+ assert!(!super::windows_capsule_region_contains_point(bounds, true, 110.0, 50.0));
+ }
+
#[test]
fn qa_anchor_uses_normal_capsule_height_source() {
#[cfg(target_os = "windows")]
diff --git a/openless-all/app/src/components/Capsule.tsx b/openless-all/app/src/components/Capsule.tsx
index 283ce712..1cb4aed6 100644
--- a/openless-all/app/src/components/Capsule.tsx
+++ b/openless-all/app/src/components/Capsule.tsx
@@ -244,7 +244,7 @@ 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;
+ const useBackdrop = os !== 'win';
return (