From 47bdbd15ebe86e2e3e268401be5ce5f1bc311e76 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E5=90=95=E6=9F=8F=E9=9D=92?= Date: Tue, 16 Jun 2026 12:33:03 +0800 Subject: [PATCH 01/26] =?UTF-8?q?fix(hotkey):=205=20=E4=B8=AA=E5=BF=AB?= =?UTF-8?q?=E6=8D=B7=20setter=20=E6=8E=A5=E5=85=A5=20Less=20Computer=20?= =?UTF-8?q?=E9=94=AE=E7=A2=B0=E6=92=9E=E6=A0=A1=E9=AA=8C=EF=BC=8C=E6=B6=88?= =?UTF-8?q?=E9=99=A4=E4=B8=8E=E9=9B=86=E4=B8=AD=E5=BC=8F=E6=A3=80=E6=B5=8B?= =?UTF-8?q?=E7=9A=84=E4=B8=8D=E4=B8=80=E8=87=B4=20(#684)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 集中式 reject_hotkey_collisions 已校验 coding_agent_voice_hotkey,但 set_dictation/translation/switch_style/open_app_hotkey(hotkeys.rs)与 set_qa_hotkey(qa.rs)这 5 个单键 setter 全部漏掉 less_computer 校验—— 用户可经单键面板把任一动作键设成与 Less Computer 键相同而不被拒,随后 完整保存又被 reject_hotkey_collisions 拒绝,两条路径不一致。 把早已存在但未接线的 reject_*_less_computer_hotkey_overlap 接进这 5 个 setter(与现有 pairwise 校验同模式);reject_qa_less_computer_hotkey_overlap 提升为 pub(crate) 供 qa.rs 调用。附 reject_hotkey_collisions 碰撞矩阵单测。 --- .../app/src-tauri/src/commands/hotkeys.rs | 68 ++++++++++++++++++- openless-all/app/src-tauri/src/commands/qa.rs | 3 + 2 files changed, 70 insertions(+), 1 deletion(-) diff --git a/openless-all/app/src-tauri/src/commands/hotkeys.rs b/openless-all/app/src-tauri/src/commands/hotkeys.rs index 2e4f712f..890c89cc 100644 --- a/openless-all/app/src-tauri/src/commands/hotkeys.rs +++ b/openless-all/app/src-tauri/src/commands/hotkeys.rs @@ -23,6 +23,9 @@ pub fn set_dictation_hotkey( if let Some(open_app) = prefs.open_app_hotkey.as_ref() { reject_dictation_open_app_hotkey_overlap(&binding, open_app)?; } + if let Some(less_computer) = prefs.coding_agent_voice_hotkey.as_ref() { + reject_dictation_less_computer_hotkey_overlap(&binding, less_computer)?; + } prefs.dictation_hotkey = binding; sync_dictation_hotkey_legacy_fields(&mut prefs); coord.prefs().set(prefs).map_err(|e| e.to_string())?; @@ -48,6 +51,9 @@ pub fn set_translation_hotkey( if let Some(open_app) = previous.open_app_hotkey.as_ref() { reject_translation_open_app_hotkey_overlap(&binding, open_app)?; } + if let Some(less_computer) = previous.coding_agent_voice_hotkey.as_ref() { + reject_translation_less_computer_hotkey_overlap(&binding, less_computer)?; + } let mut prefs = previous.clone(); prefs.translation_hotkey = binding; coord.prefs().set(prefs).map_err(|e| e.to_string())?; @@ -82,6 +88,9 @@ pub fn set_switch_style_hotkey( if let Some(open_app) = prefs.open_app_hotkey.as_ref() { reject_switch_style_open_app_hotkey_overlap(binding, open_app)?; } + if let Some(less_computer) = prefs.coding_agent_voice_hotkey.as_ref() { + reject_less_computer_switch_style_hotkey_overlap(less_computer, binding)?; + } } prefs.switch_style_hotkey = binding; coord.prefs().set(prefs).map_err(|e| e.to_string())?; @@ -109,6 +118,9 @@ pub fn set_open_app_hotkey( if let Some(switch_style) = prefs.switch_style_hotkey.as_ref() { reject_switch_style_open_app_hotkey_overlap(switch_style, binding)?; } + if let Some(less_computer) = prefs.coding_agent_voice_hotkey.as_ref() { + reject_less_computer_open_app_hotkey_overlap(less_computer, binding)?; + } } prefs.open_app_hotkey = binding; coord.prefs().set(prefs).map_err(|e| e.to_string())?; @@ -316,7 +328,7 @@ pub(crate) fn reject_qa_open_app_hotkey_overlap( reject_hotkey_overlap(qa, open_app, "打开应用快捷键不能和 QA 快捷键相同") } -fn reject_qa_less_computer_hotkey_overlap( +pub(crate) fn reject_qa_less_computer_hotkey_overlap( qa: &ShortcutBinding, less_computer: &ShortcutBinding, ) -> Result<(), String> { @@ -406,3 +418,57 @@ fn shortcut_bindings_overlap(left: &ShortcutBinding, right: &ShortcutBinding) -> } } } + +#[cfg(test)] +mod tests { + use super::*; + + fn key(primary: &str) -> ShortcutBinding { + ShortcutBinding { + primary: primary.into(), + modifiers: vec![], + } + } + + /// 锁定碰撞矩阵:每个动作键与 Less Computer 键相同都必须被 reject_hotkey_collisions + /// 拒绝。5 个快捷 setter(set_dictation/translation/switch_style/open_app/qa_hotkey) + /// 此前漏校验 coding_agent_voice_hotkey,已接入对应的 less_computer 校验。 + #[test] + fn each_action_hotkey_collides_with_less_computer() { + let lc = key("LeftControl"); + let mut prefs = UserPreferences { + dictation_hotkey: key("A"), + translation_hotkey: key("B"), + qa_hotkey: Some(key("C")), + switch_style_hotkey: Some(key("D")), + open_app_hotkey: Some(key("E")), + coding_agent_voice_hotkey: Some(lc.clone()), + ..Default::default() + }; + // 基线全不同 → 通过。 + assert!(reject_hotkey_collisions(&prefs).is_ok()); + + prefs.dictation_hotkey = lc.clone(); + assert!(reject_hotkey_collisions(&prefs).is_err()); + prefs.dictation_hotkey = key("A"); + + prefs.translation_hotkey = lc.clone(); + assert!(reject_hotkey_collisions(&prefs).is_err()); + prefs.translation_hotkey = key("B"); + + prefs.qa_hotkey = Some(lc.clone()); + assert!(reject_hotkey_collisions(&prefs).is_err()); + prefs.qa_hotkey = Some(key("C")); + + prefs.switch_style_hotkey = Some(lc.clone()); + assert!(reject_hotkey_collisions(&prefs).is_err()); + prefs.switch_style_hotkey = Some(key("D")); + + prefs.open_app_hotkey = Some(lc.clone()); + assert!(reject_hotkey_collisions(&prefs).is_err()); + prefs.open_app_hotkey = Some(key("E")); + + // 复位后再次全不同 → 通过。 + assert!(reject_hotkey_collisions(&prefs).is_ok()); + } +} diff --git a/openless-all/app/src-tauri/src/commands/qa.rs b/openless-all/app/src-tauri/src/commands/qa.rs index 779f8787..33f92254 100644 --- a/openless-all/app/src-tauri/src/commands/qa.rs +++ b/openless-all/app/src-tauri/src/commands/qa.rs @@ -29,6 +29,9 @@ pub fn set_qa_hotkey( if let Some(open_app) = prefs.open_app_hotkey.as_ref() { reject_qa_open_app_hotkey_overlap(binding, open_app)?; } + if let Some(less_computer) = prefs.coding_agent_voice_hotkey.as_ref() { + reject_qa_less_computer_hotkey_overlap(binding, less_computer)?; + } } prefs.qa_hotkey = binding; coord.prefs().set(prefs).map_err(|e| e.to_string())?; From 2de1775f3d006d172f00cb57c8bd0502f0103fff Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E5=90=95=E6=9F=8F=E9=9D=92?= Date: Tue, 16 Jun 2026 12:36:33 +0800 Subject: [PATCH 02/26] =?UTF-8?q?fix(hotkey):=20set=5Fcombo=5Fhotkey=20?= =?UTF-8?q?=E4=B9=9F=E6=8E=A5=E5=85=A5=20Less=20Computer=20=E7=A2=B0?= =?UTF-8?q?=E6=92=9E=E6=A0=A1=E9=AA=8C=20(#684)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 组合听写键 setter 同样把组合键写进 dictation_hotkey 却漏校验 less_computer, 补上 reject_dictation_less_computer_hotkey_overlap,与其余 5 个 setter 一致。 --- openless-all/app/src-tauri/src/commands/hotkeys.rs | 3 +++ 1 file changed, 3 insertions(+) diff --git a/openless-all/app/src-tauri/src/commands/hotkeys.rs b/openless-all/app/src-tauri/src/commands/hotkeys.rs index 890c89cc..b22d875e 100644 --- a/openless-all/app/src-tauri/src/commands/hotkeys.rs +++ b/openless-all/app/src-tauri/src/commands/hotkeys.rs @@ -168,6 +168,9 @@ pub fn set_combo_hotkey(coord: CoordinatorState<'_>, binding: ComboBinding) -> R if let Some(open_app) = prefs.open_app_hotkey.as_ref() { reject_dictation_open_app_hotkey_overlap(&shortcut, open_app)?; } + if let Some(less_computer) = prefs.coding_agent_voice_hotkey.as_ref() { + reject_dictation_less_computer_hotkey_overlap(&shortcut, less_computer)?; + } prefs.custom_combo_hotkey = Some(binding); prefs.dictation_hotkey = shortcut; sync_dictation_hotkey_legacy_fields(&mut prefs); From 035698950ac1984d9ec3b90616009b22cf4d4e6f Mon Sep 17 00:00:00 2001 From: sim Date: Tue, 16 Jun 2026 20:35:56 +0800 Subject: [PATCH 03/26] =?UTF-8?q?fix(capsule):=20=E6=81=A2=E5=A4=8D=20Wind?= =?UTF-8?q?ows=20=E8=83=B6=E5=9B=8A=E5=9B=9B=E8=BE=B9=20clamp=20+=20?= =?UTF-8?q?=E5=B7=A5=E4=BD=9C=E5=8C=BA=E5=AE=9A=E4=BD=8D=EF=BC=8Cclamp=5Ft?= =?UTF-8?q?o=5Fmonitor=20=E4=B8=8D=E5=86=8D=E6=98=AF=E6=AD=BB=E7=A0=81=20(?= =?UTF-8?q?#689)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit #470 曾用 fab9651(+4824080) 给 Windows 录音胶囊加了「四边 clamp + 工作区(rcWork, 避开任务栏)定位」,但 Android 移植 commit 1ea467a 把它回退成只夹上边: ForegroundMonitor 丢了 work_* 字段(不再读 rcWork),position_capsule_bottom_center 退回 y.max(mon.top) + set_position(x, clamped_y)(x/下/右都不夹、不避任务栏), clamp_to_monitor 沦为只被 test import 引用的死码,专用单测被删。 多显示器 / 负坐标 / 异常 DPI 下胶囊因此可能定位到屏幕外或压在任务栏上——正是 #470 想排查的「胶囊定位到屏幕外」子嫌疑。 按 fab9651 原样恢复(单一职责,纯 Windows 作用域,不影响 Android 的 overlay 路径): - ForegroundMonitor 重新加回 work_left/top/right/bottom,foreground_window_monitor 从 mi.rcWork 填充; - position_capsule_bottom_center 改回用 clamp_to_monitor 四边夹工作区(取不到 rcWork 退回整屏); - 恢复 clamp_to_monitor 的 5 个纯函数单测(on-screen 不动 / 右下溢出收回 / 负原点 / 避让任务栏工作区 / 病态窄屏不 panic)。 验证:cargo test --lib clamp_to_monitor 5 passed(三平台跑纯函数)。Windows 结构 + rcWork 由 Windows CI 编译验证(本机 macOS 无法交叉编译 ring 的 C 依赖)。 --- openless-all/app/src-tauri/src/lib.rs | 81 ++++++++++++++++++++++++--- 1 file changed, 74 insertions(+), 7 deletions(-) diff --git a/openless-all/app/src-tauri/src/lib.rs b/openless-all/app/src-tauri/src/lib.rs index 4b8e8903..f2e28569 100644 --- a/openless-all/app/src-tauri/src/lib.rs +++ b/openless-all/app/src-tauri/src/lib.rs @@ -2362,6 +2362,12 @@ pub(crate) struct ForegroundMonitor { pub(crate) top: i32, pub(crate) right: i32, pub(crate) bottom: i32, + /// 工作区矩形(physical px,去掉任务栏)。多端一致:胶囊优先夹到工作区内, + /// 避免压住任务栏。取不到时回退为整屏矩形。issue #470。 + pub(crate) work_left: i32, + pub(crate) work_top: i32, + pub(crate) work_right: i32, + pub(crate) work_bottom: i32, /// 该显示器的有效 DPI 缩放(1.0 = 96dpi)。 pub(crate) scale: f64, } @@ -2399,6 +2405,10 @@ pub(crate) fn foreground_window_monitor() -> Option { top: mi.rcMonitor.top, right: mi.rcMonitor.right, bottom: mi.rcMonitor.bottom, + work_left: mi.rcWork.left, + work_top: mi.rcWork.top, + work_right: mi.rcWork.right, + work_bottom: mi.rcWork.bottom, scale: (dpi_x as f64 / 96.0).max(0.1), }) } @@ -2431,15 +2441,24 @@ pub(crate) fn position_capsule_bottom_center( let offset_from_bottom = (capsule_visual_height(translation_active) + 80.0 + bounds.bottom_inset) * scale; let y = ((mon.bottom as f64) - offset_from_bottom).round() as i32; - let clamped_y = y.max(mon.top); - // #470 诊断 v2:当前只夹了上边(.max(mon.top)),未夹下/左/右。多显示器、 - // 负坐标或异常 DPI 下胶囊可能被算到屏幕外却无任何观测。记录显示器几何与 - // 最终落点,用于证伪/证实「胶囊定位到屏幕外」(C 子嫌疑)。 + + // #470:四边都夹到「工作区」内(去掉任务栏),保证整窗可见。GetMonitorInfoW + // 取不到 rcWork 时(理论上不会,rcWork 总随 rcMonitor 一同填)退回整屏矩形。 + let (work_l, work_t, work_r, work_b) = + if mon.work_right > mon.work_left && mon.work_bottom > mon.work_top { + (mon.work_left, mon.work_top, mon.work_right, mon.work_bottom) + } else { + (mon.left, mon.top, mon.right, mon.bottom) + }; + let (clamped_x, clamped_y) = + clamp_to_monitor(x, y, phys_w, phys_h, work_l, work_t, work_r, work_b); log::debug!( - "[capsule] win position: mon=({},{})..({},{}) scale={:.2} size=({}x{}) -> x={} y={} clamped_y={}", - mon.left, mon.top, mon.right, mon.bottom, scale, phys_w, phys_h, x, y, clamped_y + "[capsule] win position: mon=({},{})..({},{}) work=({},{})..({},{}) scale={:.2} size=({}x{}) -> raw=({},{}) clamped=({},{})", + mon.left, mon.top, mon.right, mon.bottom, + work_l, work_t, work_r, work_b, + scale, phys_w, phys_h, x, y, clamped_x, clamped_y ); - window.set_position(PhysicalPosition::new(x, clamped_y))?; + window.set_position(PhysicalPosition::new(clamped_x, clamped_y))?; return Ok(()); } // 仅当 Win32 取不到前台显示器时,落回下面的 current_monitor 逻辑。 @@ -2730,6 +2749,54 @@ mod tests { assert_eq!(pos, (610.0, -176.0)); } + // ---- #470: capsule 四边 clamp(纯函数,合成多显示器 / 负原点 / 1.5x DPI 输入)---- + + #[test] + fn clamp_to_monitor_leaves_on_screen_position_untouched() { + // 1080p 主屏正中偏下,整窗本就可见 → 原样返回。 + let (x, y) = clamp_to_monitor(800, 900, 264, 126, 0, 0, 1920, 1040); + assert_eq!((x, y), (800, 900)); + } + + #[test] + fn clamp_to_monitor_pulls_back_off_screen_right_and_bottom() { + // x/y 算到了屏幕右下外侧 → 收回到「右下角减去窗口尺寸」,整窗仍可见。 + let (x, y) = clamp_to_monitor(2000, 1200, 264, 126, 0, 0, 1920, 1040); + assert_eq!((x, y), (1920 - 264, 1040 - 126)); + // 整窗右/下边界都落在 area 内。 + assert!(x + 264 <= 1920); + assert!(y + 126 <= 1040); + } + + #[test] + fn clamp_to_monitor_pushes_into_negative_origin_left_monitor() { + // 副屏在主屏左侧(负 X 原点),落点算到了副屏左外侧 → 夹回 area_left。 + // 1.5x DPI 下尺寸偏大,但 area 仍宽于窗口,左上角夹到 (-2560, top)。 + let (x, y) = clamp_to_monitor(-3000, -100, 294, 138, -2560, 0, 0, 1440); + assert_eq!(x, -2560); + assert_eq!(y, 0); + // 右/下仍在 area 内。 + assert!(x >= -2560 && x + 294 <= 0); + assert!(y >= 0 && y + 138 <= 1440); + } + + #[test] + fn clamp_to_monitor_respects_work_area_above_taskbar() { + // 工作区底部 = 1040(任务栏占了 1040..1080)。落点本在任务栏区域(y=1030), + // 应被夹到「工作区底 - 窗口高」之上,胶囊整窗不压任务栏。 + let (_x, y) = clamp_to_monitor(800, 1030, 264, 126, 0, 0, 1920, 1040); + assert_eq!(y, 1040 - 126); + assert!(y + 126 <= 1040); + } + + #[test] + fn clamp_to_monitor_degrades_gracefully_when_window_wider_than_area() { + // 病态输入:area 比窗口还窄(罕见,但要保证不 panic、不溢出为负超界)。 + // max_x 钳到 area_left,clamp 把左上角收回 area_left。 + let (x, y) = clamp_to_monitor(500, 500, 800, 600, 0, 0, 400, 300); + assert_eq!((x, y), (0, 0)); + } + #[test] fn oversized_log_rotates_to_single_archive() { let dir = std::env::temp_dir().join(format!("openless-log-rotate-{}", std::process::id())); From 1429f1c51a92f7308b4482b0e97830eefff3605a Mon Sep 17 00:00:00 2001 From: sim Date: Tue, 16 Jun 2026 20:36:08 +0800 Subject: [PATCH 04/26] =?UTF-8?q?fix(local-asr):=20=E6=A8=A1=E5=9E=8B?= =?UTF-8?q?=E4=B8=8B=E8=BD=BD=E3=80=8C=E5=B7=B2=E5=AD=98=E5=9C=A8=E5=8D=B3?= =?UTF-8?q?=E8=B7=B3=E8=BF=87=E3=80=8D=E6=94=B9=E4=B8=BA=E6=A0=A1=E9=AA=8C?= =?UTF-8?q?=E6=96=87=E4=BB=B6=E5=A4=A7=E5=B0=8F=EF=BC=8C=E6=88=AA=E6=96=AD?= =?UTF-8?q?/=E6=8D=9F=E5=9D=8F=E6=96=87=E4=BB=B6=E9=87=8D=E4=B8=8B=20(#686?= =?UTF-8?q?)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit run_download 此前仅凭 dest.exists() 判定文件已下完并 continue,不比对 file.size。 上一次下载被中断或文件被外部损坏后仍残留为目标文件时,会被当成完整跳过,模型加载 时才以含糊错误失败,用户难自查。 抽纯函数 existing_file_is_complete(actual, expected):大小一致→完整;不符(截断/超大) →不完整;expected==0(HF 未给大小)→退回旧行为「存在即信任」,避免对未知大小文件反复 重下。run_download 在 dest.exists() 时按它判定:完整才跳过,否则删除残留重下; already_done_bytes 进度基线也用同一判定,保持一致。 验证:existing_file_is_complete 真值表单测 4 passed(一致/截断/超大/未知大小), cargo test --lib 全绿。 --- .../app/src-tauri/src/asr/local/download.rs | 66 ++++++++++++++++++- 1 file changed, 63 insertions(+), 3 deletions(-) diff --git a/openless-all/app/src-tauri/src/asr/local/download.rs b/openless-all/app/src-tauri/src/asr/local/download.rs index fa4d4e90..0bf8b146 100644 --- a/openless-all/app/src-tauri/src/asr/local/download.rs +++ b/openless-all/app/src-tauri/src/asr/local/download.rs @@ -242,6 +242,27 @@ pub(crate) fn build_client() -> Result { builder.build().context("build reqwest client failed") } +/// 判定一个「已存在」的目标文件是否完整可信,纯函数便于单测(#686)。 +/// - 大小一致 → 完整; +/// - 大小不符(截断 / 损坏 / 超大)→ 不完整,应删除重下; +/// - `expected_size == 0`(HF 未给出大小)→ 退回旧行为「存在即信任」,避免对未知大小 +/// 的文件反复重下。 +fn existing_file_is_complete(actual_size: u64, expected_size: u64) -> bool { + if expected_size == 0 { + return true; + } + actual_size == expected_size +} + +/// 读盘取实际大小后按 [`existing_file_is_complete`] 判定。元数据取不到(文件刚被删 / 无权限) +/// 视为不完整。读盘方式与 `partial_actual_size` 一致。 +fn dest_file_is_complete(dest: &Path, expected_size: u64) -> bool { + match std::fs::metadata(dest) { + Ok(m) => existing_file_is_complete(m.len(), expected_size), + Err(_) => false, + } +} + async fn run_download( app: &AppHandle, model_id: ModelId, @@ -308,7 +329,9 @@ async fn run_download( .iter() .map(|f| { let d = dir.join(&f.path); - if d.exists() { + // 只把「已存在且大小完整」的文件计入已完成字节,与下面的跳过判定一致: + // 截断/损坏的残留文件会被重下,不应计入进度基线(#686)。 + if dest_file_is_complete(&d, f.size) { f.size } else { 0 @@ -322,8 +345,18 @@ async fn run_download( for (idx, file) in info.files.iter().enumerate() { let dest = dir.join(&file.path); if dest.exists() { - // 已经下完的(目录里直接存在 dest 文件)跳过;前面 already_done_bytes 已计入 - continue; + if dest_file_is_complete(&dest, file.size) { + // 已存在且大小完整 → 跳过;前面 already_done_bytes 已计入。 + continue; + } + // 已存在但大小不符(上次下载被中断 / 文件被外部损坏)→ 删除残留重下, + // 否则截断文件会被信任为完整、模型加载时才以含糊错误失败(#686)。 + log::warn!( + "[asr-dl] {} exists but size mismatch (expected {}), re-downloading", + file.path, + file.size + ); + let _ = std::fs::remove_file(&dest); } let url = format!( "{}/{}/resolve/main/{}", @@ -998,3 +1031,30 @@ fn emit_cancelled( }, ); } + +#[cfg(test)] +mod tests { + use super::existing_file_is_complete; + + #[test] + fn complete_when_size_matches() { + assert!(existing_file_is_complete(1024, 1024)); + } + + #[test] + fn incomplete_when_truncated() { + assert!(!existing_file_is_complete(512, 1024)); + } + + #[test] + fn incomplete_when_oversized() { + assert!(!existing_file_is_complete(2048, 1024)); + } + + #[test] + fn trusts_existence_when_expected_size_unknown() { + // HF 未给大小(size == 0)时退回「存在即信任」,避免反复重下。 + assert!(existing_file_is_complete(0, 0)); + assert!(existing_file_is_complete(999, 0)); + } +} From f4233a6250dbfc263b5c1daac72d19a092bfa88f Mon Sep 17 00:00:00 2001 From: sim Date: Tue, 16 Jun 2026 20:36:19 +0800 Subject: [PATCH 05/26] =?UTF-8?q?fix(billing):=20polish=20=E9=87=8D?= =?UTF-8?q?=E8=AF=95=E8=87=82=E6=8E=92=E9=99=A4=E8=B6=85=E6=97=B6=EF=BC=8C?= =?UTF-8?q?=E9=81=BF=E5=85=8D=E8=AF=B7=E6=B1=82=E4=BD=93=E5=86=99=E5=87=BA?= =?UTF-8?q?=E8=B6=85=E6=97=B6=E9=87=8D=E5=8F=91=E9=9D=9E=E5=B9=82=E7=AD=89?= =?UTF-8?q?=20LLM=20=E8=AF=B7=E6=B1=82=20(#680)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit send_with_transient_retry 的文档明确「is_timeout() 故意不重试——超时时服务端可能已在 处理并扣费」,但重试臂 guard 是 e.is_connect() || e.is_request()。reqwest 把「请求体 写出阶段超时」归类为 is_request()(有时同时 is_timeout()),这类错误会先命中重试臂、 到不了 timeout 分支,于是重发已发出的非幂等 chat/completions → 重复 completion + 重复插入文本 + 供应商双重计费,与文档意图及 net.rs 的 send_with_retry(仅 is_connect) 矛盾。 抽纯函数 should_retry_transient(is_connect, is_request, is_timeout) = (connect||request) && !timeout,重试 guard 改用它,使超时类错误一律不重试。纯函数便于单测(reqwest::Error 无法在测试里构造任意 flag 组合)。 验证:retries_connect_or_request_only_when_not_timeout 单测覆盖 6 组组合 passed, cargo test --lib 全绿。 --- openless-all/app/src-tauri/src/polish.rs | 27 +++++++++++++++++++++++- 1 file changed, 26 insertions(+), 1 deletion(-) diff --git a/openless-all/app/src-tauri/src/polish.rs b/openless-all/app/src-tauri/src/polish.rs index e80f9b8f..b724cedf 100644 --- a/openless-all/app/src-tauri/src/polish.rs +++ b/openless-all/app/src-tauri/src/polish.rs @@ -1250,6 +1250,17 @@ pub(crate) fn http_client_builder(base_url: &str, timeout_secs: u64) -> reqwest: } } +/// 判定一个「TCP 握手 / 请求写出」阶段的网络错误是否可安全重试。 +/// +/// 只对 connect / request 这两类「服务端必然没收到」的失败重试,且**必须排除超时**: +/// reqwest 会把「请求体写出阶段超时」归类为 `is_request()`(有时同时 `is_timeout()`), +/// 若只判 `is_connect() || is_request()` 会让这类超时先命中重试臂,重发已发出的非幂等 +/// 请求 → 重复 LLM completion + 双重计费,与本函数文档意图相悖(#680)。抽成纯函数便于 +/// 单测覆盖(reqwest::Error 无法在测试里构造任意 flag 组合)。 +fn should_retry_transient(is_connect: bool, is_request: bool, is_timeout: bool) -> bool { + (is_connect || is_request) && !is_timeout +} + /// 发请求 + 网络抖动 retry:**只**对 `is_connect()` / `is_request()` 这两类「服务端 /// 必然没收到」的失败重试一次。`is_timeout()` 故意**不**重试——超时时服务端可能已经 /// 在处理请求并扣计费(LLM completion 是非幂等动作),重试会导致重复 billing + 重复 @@ -1277,7 +1288,7 @@ async fn send_with_transient_retry( }; match initial.send().await { Ok(r) => Ok(r), - Err(e) if e.is_connect() || e.is_request() => { + Err(e) if should_retry_transient(e.is_connect(), e.is_request(), e.is_timeout()) => { log::warn!( "[llm] send transient failure, retry in {}ms: {}", RETRY_DELAY_MS, @@ -2464,6 +2475,20 @@ mod tests { static CODEX_AUTH_FIXTURE_COUNTER: AtomicU64 = AtomicU64::new(0); static ENV_LOCK: StdMutex<()> = StdMutex::new(()); + #[test] + fn retries_connect_or_request_only_when_not_timeout() { + // connect / request 失败(非超时)→ 服务端必然没收到,重试安全。 + assert!(should_retry_transient(true, false, false)); + assert!(should_retry_transient(false, true, false)); + // 请求体写出阶段超时(reqwest 归类 is_request + is_timeout)→ 服务端可能已扣费, + // 不重试,避免重复 LLM completion 与双重计费(#680)。 + assert!(!should_retry_transient(false, true, true)); + assert!(!should_retry_transient(true, false, true)); + // 纯超时 / 其它错误也不重试。 + assert!(!should_retry_transient(false, false, true)); + assert!(!should_retry_transient(false, false, false)); + } + struct EnvSnapshot { values: Vec<(&'static str, Option)>, } From d29c103fba8d874ca28f999b4b1fbc54646e445a Mon Sep 17 00:00:00 2001 From: sim Date: Tue, 16 Jun 2026 20:36:37 +0800 Subject: [PATCH 06/26] =?UTF-8?q?fix(coord):=20emit=20remote:result=20?= =?UTF-8?q?=E5=9B=9E=E4=BC=A0=E6=89=8B=E6=9C=BA=E7=AB=AF=20(#691)=20+=20?= =?UTF-8?q?=E9=99=90=E5=88=B6=E6=B6=A6=E8=89=B2=E5=A4=9A=E8=BD=AE=E4=B8=8A?= =?UTF-8?q?=E4=B8=8B=E6=96=87=E6=9D=A1=E6=95=B0=E9=99=8D=E9=A6=96=E5=AD=97?= =?UTF-8?q?=E5=BB=B6=E8=BF=9F=20(#678)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 两处独立改动,同属 coordinator/dictation 听写收尾路径: #691(remote):remote_server 的 WS handler 订阅了 "remote:result"(mod.rs:614)并把 最终文字转发给手机,H5 也准备好显示,但全仓从未 emit 过这个事件,导致手机端结果区永远空。 在听写 finalize(history.append_with_retention 之后)按已有 vocab:updated 同模式 emit polished 到 "remote:result";非空才发,无手机连接时无人转发 = 无害空操作。 #678(perf, P1):对话上下文感知润色把「最近 N 分钟内的全部历史轮」前置进 LLM,时间窗口 只限「多久内」不限「多少条」,eligible_polish_context_turns 末尾直接 .collect() 无上限。 5 分钟内堆积几十条历史时输入 token 暴涨、首字延迟(TTFT)显著变长,影响全体用户。 给该函数加 MAX_POLISH_CONTEXT_TURNS=2 上限(sessions newest-first,.take 取最近 2 轮, build_polish_history_messages 仍 .rev() 成时间正序),保留代词/续写所需的连续性,把上下文 token 控制在常数量级。 验证:polish_context_caps_at_max_turns_keeping_most_recent 单测 passed,cargo test --lib 全绿;#691 需手机真机验证结果区出现最终文字。 --- .../src-tauri/src/coordinator/dictation.rs | 40 +++++++++++++++++++ 1 file changed, 40 insertions(+) diff --git a/openless-all/app/src-tauri/src/coordinator/dictation.rs b/openless-all/app/src-tauri/src/coordinator/dictation.rs index cd5e263c..5f1139a5 100644 --- a/openless-all/app/src-tauri/src/coordinator/dictation.rs +++ b/openless-all/app/src-tauri/src/coordinator/dictation.rs @@ -2406,6 +2406,16 @@ pub(super) async fn end_session(inner: &Arc) -> Result<(), String> { ) { log::error!("[coord] history append failed: {e}"); } + + // 远程输入:把本次最终文字回传给手机端。remote_server 的 WS handler 订阅了 + // "remote:result"(mod.rs:614),但此前全仓从未 emit,导致手机结果区永远空(#691)。 + // 与上面的 vocab:updated 同模式:无手机连接时无人转发 = 无害空操作。 + if !polished.trim().is_empty() { + if let Some(app) = inner.app.lock().clone() { + let _ = app.emit("remote:result", polished.clone()); + } + } + let done_message = if tsf_required_insert_failed { Some("TSF 未上屏,已禁止非 TSF 兜底".to_string()) } else { @@ -2505,6 +2515,12 @@ fn append_typed_prefix(target: &mut String, delta: &str, typed_chars: usize) -> appended } +/// 多轮上下文最多回看的历史轮数。时间窗口(polish_context_window_minutes)只限"多久内", +/// 不限"多少条"——5 分钟内堆积几十条历史时,全部前置进 LLM 会让输入 token 暴涨、首字延迟 +/// (TTFT)显著变长,影响全体用户(#678)。取最近 2 轮即可保留代词/续写所需的对话连续性, +/// 同时把上下文 token 控制在常数量级。sessions 为 newest-first,`.take` 即取最近若干轮。 +const MAX_POLISH_CONTEXT_TURNS: usize = 2; + fn eligible_polish_context_turns( sessions: Vec, active_style_pack_id: &str, @@ -2533,6 +2549,8 @@ fn eligible_polish_context_turns( Some((s.raw_transcript, s.final_text)) } }) + // 限制条数:sessions newest-first,过滤后取最近 MAX_POLISH_CONTEXT_TURNS 轮(#678)。 + .take(MAX_POLISH_CONTEXT_TURNS) .collect() } @@ -2585,6 +2603,28 @@ mod tests { } } + #[test] + fn polish_context_caps_at_max_turns_keeping_most_recent() { + // sessions newest-first:超过上限时只保留最近 MAX_POLISH_CONTEXT_TURNS 轮(#678)。 + let sessions = vec![ + history_session("t1", "raw1", "final1", Some("pack.id"), false, None), + history_session("t2", "raw2", "final2", Some("pack.id"), false, None), + history_session("t3", "raw3", "final3", Some("pack.id"), false, None), + history_session("t4", "raw4", "final4", Some("pack.id"), false, None), + ]; + + let turns = eligible_polish_context_turns(sessions, "pack.id", false); + + assert_eq!(turns.len(), super::MAX_POLISH_CONTEXT_TURNS); + assert_eq!( + turns, + vec![ + ("raw1".to_string(), "final1".to_string()), + ("raw2".to_string(), "final2".to_string()), + ] + ); + } + #[test] fn polish_context_resets_when_active_style_pack_changes() { let sessions = vec![ From eee83728190e50398e70ec4022b197b50aa38dd1 Mon Sep 17 00:00:00 2001 From: sim Date: Tue, 16 Jun 2026 20:36:49 +0800 Subject: [PATCH 07/26] =?UTF-8?q?fix(history):=20=E5=8E=86=E5=8F=B2?= =?UTF-8?q?=E9=A1=B5=E6=90=9C=E7=B4=A2=E6=A1=86=E6=94=B9=E4=B8=BA=E5=8F=AF?= =?UTF-8?q?=E8=BE=93=E5=85=A5=E7=9A=84=20input=EF=BC=8C=E6=8C=89=E8=BD=AC?= =?UTF-8?q?=E5=86=99=E5=86=85=E5=AE=B9=E5=AE=9E=E6=97=B6=E8=BF=87=E6=BB=A4?= =?UTF-8?q?=20(#612)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 历史页顶部搜索区此前是静态 div(只显示「共 N 条 · 显示 M」统计),无法点击聚焦、无法输入, 外观像搜索框却不可交互。设计稿与全部 5 个 locale 早已备好 history.searchPlaceholder / searchNoMatch(含 ⌘K 提示)翻译,只是从未接线。 仿 Marketplace 搜索框: - 静态 span 改为 ,query 状态 + 300ms 防抖(debouncedQuery); - filtered 在 mode 过滤基础上叠加按 rawTranscript / finalText 不区分大小写匹配; - 清空搜索词恢复显示全部(当前 mode 下); - ⌘K / Ctrl+K 聚焦搜索框(设计稿提示); - 搜索无结果时显示 searchNoMatch 而非误导性的「无历史」空态。 复用既有 i18n key,无新增翻译。验证:tsc --noEmit 通过,npm run build 通过, 三个 contract 脚本(aura-skin / macos-capsule-spaces / hotkey-injection)全绿。 --- openless-all/app/src/pages/History.tsx | 62 +++++++++++++++++++++++--- 1 file changed, 55 insertions(+), 7 deletions(-) diff --git a/openless-all/app/src/pages/History.tsx b/openless-all/app/src/pages/History.tsx index e10d0ea2..0362af1d 100644 --- a/openless-all/app/src/pages/History.tsx +++ b/openless-all/app/src/pages/History.tsx @@ -1,7 +1,7 @@ // History.tsx — 接 Tauri 后端 list_history / delete_history_entry / clear_history。 // 真实数据来自 ~/Library/Application Support/OpenLess/history.json。 -import { useCallback, useEffect, useMemo, useState } from 'react'; +import { useCallback, useEffect, useMemo, useRef, useState } from 'react'; import { useTranslation } from 'react-i18next'; import { Icon } from '../components/Icon'; import { detectOS } from '../components/WindowChrome'; @@ -40,6 +40,8 @@ export function History() { const FILTERS = useFilters(); const MODE_LABEL = useModeLabel(); const [filter, setFilter] = useState<'all' | PolishMode>('all'); + const [query, setQuery] = useState(''); + const [debouncedQuery, setDebouncedQuery] = useState(''); const [items, setItems] = useState([]); const [selectedId, setSelectedId] = useState(null); const [loading, setLoading] = useState(true); @@ -84,10 +86,39 @@ export function History() { void refresh(); }, [refresh]); - const filtered = useMemo( - () => (filter === 'all' ? items : items.filter(s => s.mode === filter)), - [items, filter], - ); + const searchInputRef = useRef(null); + const searchShortcut = os === 'mac' ? '⌘K' : 'Ctrl+K'; + + // 搜索词防抖:随输入实时更新 query,300ms 后落到 debouncedQuery 再过滤, + // 避免每个按键都重算整张列表(与 Marketplace 同模式)。 + useEffect(() => { + const id = window.setTimeout(() => setDebouncedQuery(query), 300); + return () => window.clearTimeout(id); + }, [query]); + + // ⌘K / Ctrl+K 聚焦搜索框(设计稿提示的快捷键)。 + useEffect(() => { + const onKeyDown = (e: KeyboardEvent) => { + if ((e.metaKey || e.ctrlKey) && (e.key === 'k' || e.key === 'K')) { + e.preventDefault(); + searchInputRef.current?.focus(); + } + }; + window.addEventListener('keydown', onKeyDown); + return () => window.removeEventListener('keydown', onKeyDown); + }, []); + + const filtered = useMemo(() => { + const byMode = filter === 'all' ? items : items.filter(s => s.mode === filter); + const q = debouncedQuery.trim().toLowerCase(); + if (!q) return byMode; + // 按原始转写 + 润色后文本匹配关键词,覆盖用户能想起的两种内容。 + return byMode.filter( + s => + s.rawTranscript.toLowerCase().includes(q) || + s.finalText.toLowerCase().includes(q), + ); + }, [items, filter, debouncedQuery]); const item = useMemo( () => filtered.find(s => s.id === selectedId) || filtered[0], [filtered, selectedId], @@ -191,7 +222,22 @@ export function History() { background: 'var(--ol-surface-2)', color: 'var(--ol-ink-3)', }}> - {t('history.summary', { total: items.length, shown: filtered.length })} + setQuery(e.target.value)} + placeholder={t('history.searchPlaceholder', { shortcut: searchShortcut })} + aria-label={t('history.searchPlaceholder', { shortcut: searchShortcut })} + style={{ + flex: 1, minWidth: 0, + outline: 'none', border: 0, background: 'transparent', + fontSize: 12, color: 'var(--ol-ink-1)', fontFamily: 'inherit', + }} + /> + +
+ {t('history.summary', { total: items.length, shown: filtered.length })}
{FILTERS.map(f => ( @@ -223,7 +269,9 @@ export function History() { )} {!loading && !loadError && filtered.length === 0 && (
- {t('history.empty', { trigger: prefs ? formatComboLabel(prefs.dictationHotkey) : '' })} + {debouncedQuery.trim() + ? t('history.searchNoMatch', { query: debouncedQuery.trim() }) + : t('history.empty', { trigger: prefs ? formatComboLabel(prefs.dictationHotkey) : '' })}
)} {!loadError && filtered.map(s => ( From a0718b7a72c59075d2a07e9fecdd67ccd221f482 Mon Sep 17 00:00:00 2001 From: sim Date: Tue, 16 Jun 2026 20:44:22 +0800 Subject: [PATCH 08/26] =?UTF-8?q?chore(ui):=20remove=20unused=20Aura=20ski?= =?UTF-8?q?n=20layer=20(docs/contract/tokens)=20=E2=80=94=20appearance=20u?= =?UTF-8?q?nchanged?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Aura skin 整体弃用。删除脚手架与去掉 --ol-aura-* 设计令牌层,渲染外观(明/暗)保持完全一致—— 纯重命名/清理,无视觉变化。 删除: - docs/superpowers/plans/2026-06-05-openless-aura-skin.md - docs/superpowers/specs/2026-06-05-openless-aura-skin-design.md - scripts/aura-skin-contract.test.mjs 及 package.json 的 check:aura-skin 脚本 令牌层(视觉中性): - 把 --ol-panel-bg/border/shadow、--ol-card-shadow 这几个 light 别名里对 --ol-aura-* 的 引用就地内联为其解析后的字面值(dark 别名本就不引用 aura,原样不动); - --ol-aura-surface(FloatingShell 主区玻璃背景,需主题感知)重命名为 --ol-panel-glass-bg, 明/暗取值原样保留; - 删除其余未被消费的 --ol-aura-* 定义(surface-2/solid、line*、glass-blur 等)。 类名层(视觉中性重命名): - .ol-aura-panel→.ol-panel-surface、.ol-aura-card→.ol-card-surface、 .ol-aura-settings→.ol-settings-surface,及 FloatingShell 的 ol-aura-sidebar→ol-sidebar-surface (该类无 CSS 规则,纯 no-op className)。 验证:grep -ri aura src 无输出;check:aura-skin 脚本已不存在;npx tsc --noEmit 通过; npm run build 通过;check:macos-capsule-spaces / check:hotkey-injection 仍全绿。明/暗两套 别名解析值与改前逐一比对一致。 --- .../plans/2026-06-05-openless-aura-skin.md | 541 ------------------ .../2026-06-05-openless-aura-skin-design.md | 254 -------- openless-all/app/package.json | 1 - .../app/scripts/aura-skin-contract.test.mjs | 512 ----------------- .../app/src/components/FloatingShell.tsx | 6 +- .../app/src/components/SettingsModal.tsx | 2 +- openless-all/app/src/styles/global.css | 8 +- openless-all/app/src/styles/tokens.css | 41 +- 8 files changed, 20 insertions(+), 1345 deletions(-) delete mode 100644 docs/superpowers/plans/2026-06-05-openless-aura-skin.md delete mode 100644 docs/superpowers/specs/2026-06-05-openless-aura-skin-design.md delete mode 100644 openless-all/app/scripts/aura-skin-contract.test.mjs diff --git a/docs/superpowers/plans/2026-06-05-openless-aura-skin.md b/docs/superpowers/plans/2026-06-05-openless-aura-skin.md deleted file mode 100644 index 97ebb636..00000000 --- a/docs/superpowers/plans/2026-06-05-openless-aura-skin.md +++ /dev/null @@ -1,541 +0,0 @@ -# OpenLess Aura Skin Implementation Plan - -> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking. - -**Goal:** Apply an Aura-inspired visual skin to OpenLess by upgrading the shell, glass treatment, radii, typography, and overview surfaces while preserving the current information architecture and page density. - -**Architecture:** The implementation stays shallow and visual-only. It starts by adding a contract test that defines the new skin hooks, then updates global tokens and chrome, then reskins the main shell and settings modal, and finally aligns shared atoms and the overview page to the new surface system. - -**Tech Stack:** React 18, TypeScript, Vite 5, inline-style component patterns, CSS token files, Node-based contract checks - ---- - -## File Structure - -- Modify: `openless-all/app/package.json` - Purpose: register a repeatable Aura skin contract check command. -- Create: `openless-all/app/scripts/aura-skin-contract.test.mjs` - Purpose: assert the new skin tokens and shell hooks exist before visual work is considered complete. -- Modify: `openless-all/app/src/styles/tokens.css` - Purpose: replace the current light-glass token ladder with the new Aura-inspired radii, surfaces, shadows, and typography. -- Modify: `openless-all/app/src/styles/global.css` - Purpose: add the static layered background and shared shell/panel helper classes without introducing animation. -- Modify: `openless-all/app/src/components/FloatingShell.tsx` - Purpose: reskin the app shell, sidebar, version zone, and main console wrapper. -- Modify: `openless-all/app/src/components/SettingsModal.tsx` - Purpose: align the settings modal shell and left rail with the new skin system. -- Modify: `openless-all/app/src/pages/_atoms.tsx` - Purpose: make cards, buttons, pills, and page headers inherit the new radius and surface vocabulary. -- Modify: `openless-all/app/src/pages/Overview.tsx` - Purpose: make the overview page visibly match the new shell without changing its data structure. - -### Task 1: Add Aura Skin Contract Check - -**Files:** -- Create: `openless-all/app/scripts/aura-skin-contract.test.mjs` -- Modify: `openless-all/app/package.json` -- Test: `openless-all/app/scripts/aura-skin-contract.test.mjs` - -- [ ] **Step 1: Write the failing test** - -Create `openless-all/app/scripts/aura-skin-contract.test.mjs`: - -```js -import { readFile } from 'node:fs/promises'; -import assert from 'node:assert/strict'; - -const root = new URL('../', import.meta.url); - -async function read(relPath) { - return readFile(new URL(relPath, root), 'utf8'); -} - -const [tokens, globalCss, shell, settingsModal, overview] = await Promise.all([ - read('src/styles/tokens.css'), - read('src/styles/global.css'), - read('src/components/FloatingShell.tsx'), - read('src/components/SettingsModal.tsx'), - read('src/pages/Overview.tsx'), -]); - -assert.match(tokens, /--ol-shell-radius:/, 'tokens.css must define --ol-shell-radius'); -assert.match(tokens, /--ol-panel-radius:/, 'tokens.css must define --ol-panel-radius'); -assert.match(tokens, /--ol-aura-shadow:/, 'tokens.css must define --ol-aura-shadow'); -assert.match(tokens, /--ol-font-display:/, 'tokens.css must define --ol-font-display'); - -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'); - -assert.match(shell, /ol-app-shell-bg/, 'FloatingShell must use the app shell background class'); -assert.match(shell, /ol-aura-sidebar/, 'FloatingShell must expose an Aura sidebar hook'); -assert.match(shell, /ol-aura-panel/, 'FloatingShell must expose an Aura panel hook'); - -assert.match(settingsModal, /ol-aura-settings/, 'SettingsModal must expose an Aura settings wrapper'); -assert.match(overview, /ol-overview-hero/, 'Overview must expose a high-visibility overview surface hook'); - -console.log('Aura skin contract OK'); -``` - -- [ ] **Step 2: Register the check in `package.json`** - -Modify `openless-all/app/package.json`: - -```json -{ - "scripts": { - "dev": "vite", - "build": "tsc && vite build", - "preview": "vite preview", - "tauri": "tauri", - "check:aura-skin": "node scripts/aura-skin-contract.test.mjs", - "check:macos-capsule-spaces": "node scripts/macos-capsule-spaces-contract.test.mjs", - "check:hotkey-injection": "node scripts/check-hotkey-injection.mjs" - } -} -``` - -- [ ] **Step 3: Run the test to verify it fails** - -Run: - -```bash -npm run check:aura-skin -``` - -Expected: - -```text -AssertionError [ERR_ASSERTION]: tokens.css must define --ol-shell-radius -``` - -- [ ] **Step 4: Commit the failing test harness** - -```bash -git add openless-all/app/package.json openless-all/app/scripts/aura-skin-contract.test.mjs -git commit -m "test: add aura skin contract" -``` - -### Task 2: Replace Global Tokens and App Chrome - -**Files:** -- Modify: `openless-all/app/src/styles/tokens.css` -- Modify: `openless-all/app/src/styles/global.css` -- Test: `openless-all/app/scripts/aura-skin-contract.test.mjs` - -- [ ] **Step 1: Update the token ladder** - -Replace the current token center in `openless-all/app/src/styles/tokens.css` with the Aura skin ladder: - -```css -:root { - --ol-bg-base: #f3f4f6; - --ol-bg-elevated: #fbfbfc; - --ol-surface: rgba(255, 255, 255, 0.72); - --ol-surface-2: rgba(255, 255, 255, 0.88); - --ol-surface-solid: #ffffff; - - --ol-line: rgba(15, 23, 42, 0.08); - --ol-line-strong: rgba(15, 23, 42, 0.14); - --ol-line-soft: rgba(255, 255, 255, 0.55); - - --ol-ink: #10131a; - --ol-ink-2: #222938; - --ol-ink-3: rgba(16, 19, 26, 0.64); - --ol-ink-4: rgba(16, 19, 26, 0.42); - --ol-ink-5: rgba(16, 19, 26, 0.24); - - --ol-blue: #2f6df6; - --ol-blue-hover: #2458c8; - --ol-blue-soft: rgba(47, 109, 246, 0.10); - --ol-blue-ring: rgba(47, 109, 246, 0.22); - - --ol-shell-radius: 32px; - --ol-panel-radius: 28px; - --ol-card-radius: 22px; - --ol-pill-radius: 999px; - - --ol-aura-shadow: 0 24px 80px -32px rgba(15, 23, 42, 0.24), 0 12px 36px -18px rgba(15, 23, 42, 0.12); - --ol-aura-shadow-soft: 0 10px 30px -18px rgba(15, 23, 42, 0.16), 0 0 0 0.5px rgba(255, 255, 255, 0.55) inset; - - --ol-font-display: "Aptos", "Segoe UI Variable Display", "PingFang SC", "Microsoft YaHei", sans-serif; - --ol-font-sans: "Aptos", "Segoe UI Variable Text", "PingFang SC", "Microsoft YaHei", sans-serif; - --ol-font-mono: "JetBrains Mono", "SF Mono", "Cascadia Code", Consolas, monospace; -} -``` - -- [ ] **Step 2: Add static Aura shell helpers** - -Append shared helpers to `openless-all/app/src/styles/global.css`: - -```css -body { - background: - radial-gradient(circle at 18% 12%, rgba(255, 255, 255, 0.92), rgba(255, 255, 255, 0) 34%), - radial-gradient(circle at 86% 18%, rgba(47, 109, 246, 0.08), rgba(47, 109, 246, 0) 30%), - linear-gradient(180deg, #f6f7fa 0%, #eef1f6 100%); - color: var(--ol-ink); -} - -.ol-app-shell-bg { - background: - radial-gradient(circle at top left, rgba(255, 255, 255, 0.68), rgba(255, 255, 255, 0) 38%), - linear-gradient(180deg, rgba(255, 255, 255, 0.68), rgba(245, 247, 251, 0.58)); -} - -.ol-aura-panel { - background: var(--ol-surface); - backdrop-filter: blur(24px) saturate(150%); - -webkit-backdrop-filter: blur(24px) saturate(150%); - border: 1px solid rgba(255, 255, 255, 0.58); - box-shadow: var(--ol-aura-shadow); -} - -.ol-aura-card { - background: linear-gradient(180deg, rgba(255, 255, 255, 0.92), rgba(252, 252, 253, 0.82)); - border: 1px solid rgba(255, 255, 255, 0.74); - box-shadow: var(--ol-aura-shadow-soft); -} -``` - -- [ ] **Step 3: Run the contract check** - -Run: - -```bash -npm run check:aura-skin -``` - -Expected: - -```text -AssertionError [ERR_ASSERTION]: FloatingShell must use the app shell background class -``` - -- [ ] **Step 4: Commit the token/chrome layer** - -```bash -git add openless-all/app/src/styles/tokens.css openless-all/app/src/styles/global.css -git commit -m "feat: add aura skin tokens and chrome" -``` - -### Task 3: Reskin the Shell and Settings Modal - -**Files:** -- Modify: `openless-all/app/src/components/FloatingShell.tsx` -- Modify: `openless-all/app/src/components/SettingsModal.tsx` -- Test: `openless-all/app/scripts/aura-skin-contract.test.mjs` - -- [ ] **Step 1: Apply Aura shell hooks in `FloatingShell.tsx`** - -Update the shell wrappers in `openless-all/app/src/components/FloatingShell.tsx`: - -```tsx -
-``` - -Update the sidebar and main panel wrappers: - -```tsx -