diff --git a/desktop/src-tauri/src/proxy.rs b/desktop/src-tauri/src/proxy.rs index d90e650..f897bdb 100644 --- a/desktop/src-tauri/src/proxy.rs +++ b/desktop/src-tauri/src/proxy.rs @@ -246,13 +246,18 @@ async fn login_page() -> Html { let html = LOGIN_HTML .replace("HugAgentOS", brand::NAME) .replace("/icon.png", brand::LOGIN_LOGO_URL); - Html(inject_after_body(&html, &platform_titlebar_block(false))) + Html(inject_after_body( + &with_theme_boot(&html), + &platform_titlebar_block(false), + )) } /// 关闭主窗口时的自定义确认页(带「记住我的选择」勾选框)。按钮整页导航到 /// `/__desktop/close-decide?action=..&remember=..`,由确认窗的 Rust 导航守卫执行。 async fn close_confirm_page() -> Html { - Html(CLOSE_CONFIRM_HTML.replace("HugAgentOS", brand::NAME)) + Html(with_theme_boot( + &CLOSE_CONFIRM_HTML.replace("HugAgentOS", brand::NAME), + )) } /// 「设置服务器地址」页(菜单栏「文件 → 设置服务器地址…」打开)。输入框预填当前后端地址, @@ -262,7 +267,10 @@ async fn server_config_page(State(state): State) -> Html { let html = SERVER_CONFIG_HTML .replace("__CURRENT_BASE__", &html_escape(&state.server_base)) .replace("HugAgentOS", brand::NAME); - Html(inject_after_body(&html, &platform_titlebar_block(false))) + Html(inject_after_body( + &with_theme_boot(&html), + &platform_titlebar_block(false), + )) } /// 后端不可达或用户在安装器选择本机服务时展示的一体化部署页。 @@ -300,7 +308,10 @@ async fn setup_page(State(state): State) -> Html { }, ) .replace("HugAgentOS", brand::NAME); - Html(inject_after_body(&html, &platform_titlebar_block(false))) + Html(inject_after_body( + &with_theme_boot(&html), + &platform_titlebar_block(false), + )) } /// 初始化「运行模式选择」页(首启时展示):下拉选本机 / 云端 / 双模式;选到含云端的 @@ -343,7 +354,10 @@ async fn init_page(State(state): State) -> Html { }, ) .replace("HugAgentOS", brand::NAME); - Html(inject_after_body(&html, &platform_titlebar_block(false))) + Html(inject_after_body( + &with_theme_boot(&html), + &platform_titlebar_block(false), + )) } #[derive(serde::Serialize)] @@ -398,32 +412,40 @@ const TB_OFFSET_PAGE: &str = // their hit area clear without stacking a second, visibly empty toolbar above // the application's own brand row. const MAC_TITLEBAR_HEIGHT: u8 = 28; -// The left half of the macOS safe area reuses the sidebar's first translucent -// blue gradient stop over its #F5F6F7 base. The traffic lights therefore sit -// on a true visual continuation of the sidebar instead of a detached grey bar. +// 左半幅要在视觉上**接着侧边栏往上长**,所以这里必须逐字复刻 sidebar.css 里 `.jx-sider` +// 的配方:`color-mix(in srgb, var(--color-bg-gray) 72%, transparent)` 压在页面底色上。 +// +// 这段历史上写死过 `rgba(203,223,255,.38)` / `#FFFFFF` / `#F5F6F7`,是抄的侧边栏当年那版 +// 浅蓝。前端把侧边栏令牌化之后它就双重失真了:浅色下不再和侧边栏同色,深色下更是用 +// `!important` 把整个 body 底色摁回浅色 —— 深色模式在 macOS 客户端里直接不成立。 +// 改引令牌后两档自动跟随,侧边栏配方再变也只需要改 sidebar.css 一处。 const MAC_OFFSET_SPA: &str = - ":root{--hugagent-desktop-titlebar-height:28px;--hugagent-desktop-sidebar-width:0px}body{box-sizing:border-box!important;padding-top:28px!important;background:linear-gradient(90deg,rgba(203,223,255,.38) 0 var(--hugagent-desktop-sidebar-width),#FFFFFF var(--hugagent-desktop-sidebar-width) 100%),#F5F6F7!important}.jx-brandRow,.jx-miniRail{padding-top:0!important}.jx-appLoading{height:100%!important}.ant-message{top:calc(var(--hugagent-desktop-titlebar-height) + 8px)!important}.ant-notification-top,.ant-notification-topLeft,.ant-notification-topRight{top:calc(var(--hugagent-desktop-titlebar-height) + 24px)!important}"; + ":root{--hugagent-desktop-titlebar-height:28px;--hugagent-desktop-sidebar-width:0px}body{box-sizing:border-box!important;padding-top:28px!important;background:linear-gradient(90deg,color-mix(in srgb, var(--color-bg-gray) 72%, transparent) 0 var(--hugagent-desktop-sidebar-width),var(--color-bg-base) var(--hugagent-desktop-sidebar-width) 100%),var(--color-bg-layout)!important}.jx-brandRow,.jx-miniRail{padding-top:0!important}.jx-appLoading{height:100%!important}.ant-message{top:calc(var(--hugagent-desktop-titlebar-height) + 8px)!important}.ant-notification-top,.ant-notification-topLeft,.ant-notification-topRight{top:calc(var(--hugagent-desktop-titlebar-height) + 24px)!important}"; const MAC_OFFSET_PAGE: &str = ":root{--hugagent-desktop-titlebar-height:28px}body{box-sizing:border-box!important;padding-top:28px!important}.ant-message{top:calc(var(--hugagent-desktop-titlebar-height) + 8px)!important}.ant-notification-top,.ant-notification-topLeft,.ant-notification-topRight{top:calc(var(--hugagent-desktop-titlebar-height) + 24px)!important}"; +// 这条标题栏是**注进 SPA 自己那份文档**的(见 inject_after_body),所以 `` 上的 +// data-theme 对它同样生效,直接引用应用令牌即可两档自动跟随 —— 不需要再写一套深色覆盖, +// 也不需要 prefers-color-scheme(那会和手动 light/dark/system 三档打架)。 const TB_CSS: &str = r##" -#hugagent-titlebar{position:fixed;inset:0 0 auto 0;height:36px;z-index:2147483647;display:flex;align-items:center;background:#F7F8FA;border-bottom:1px solid #E5E9EF;font-family:-apple-system,BlinkMacSystemFont,"Segoe UI","Microsoft YaHei",sans-serif;color:#30343B} +#hugagent-titlebar{position:fixed;inset:0 0 auto 0;height:36px;z-index:2147483647;display:flex;align-items:center;background:var(--color-bg-gray);border-bottom:1px solid var(--color-border);font-family:-apple-system,BlinkMacSystemFont,"Segoe UI","Microsoft YaHei",sans-serif;color:var(--color-text)} #hugagent-titlebar *{box-sizing:border-box} #hugagent-titlebar .tb-left{display:flex;align-items:center;height:100%;min-width:0;padding-left:8px} #hugagent-titlebar .tb-logo{width:17px;height:17px;border-radius:4px;margin-right:5px;object-fit:cover} #hugagent-titlebar .tb-spacer{flex:1;height:100%;min-width:30px} #hugagent-titlebar .tb-menu{display:flex;align-items:stretch;height:100%} #hugagent-titlebar .tb-menuGroup{position:relative;height:100%;display:flex;align-items:stretch} -#hugagent-titlebar .tb-menuLabel{height:100%;padding:0 10px;border:0;background:transparent;color:#41464C;font:12.5px/1 inherit;cursor:default;-webkit-user-select:none;user-select:none} -#hugagent-titlebar .tb-menuLabel:hover,#hugagent-titlebar .tb-menuGroup.open>.tb-menuLabel{background:#E7EBF1} -#hugagent-titlebar .tb-drop{display:none;position:absolute;top:35px;left:0;min-width:180px;padding:5px;background:#fff;border:1px solid #DEE3EA;border-radius:8px;box-shadow:0 10px 28px rgba(15,23,42,.16)} +#hugagent-titlebar .tb-menuLabel{height:100%;padding:0 10px;border:0;background:transparent;color:var(--color-text-secondary);font:12.5px/1 inherit;cursor:default;-webkit-user-select:none;user-select:none} +#hugagent-titlebar .tb-menuLabel:hover,#hugagent-titlebar .tb-menuGroup.open>.tb-menuLabel{background:var(--color-fill-hover)} +#hugagent-titlebar .tb-drop{display:none;position:absolute;top:35px;left:0;min-width:180px;padding:5px;background:var(--color-bg-elevated);border:1px solid var(--color-border);border-radius:8px;box-shadow:0 10px 28px color-mix(in srgb, var(--color-text) 16%, transparent)} #hugagent-titlebar .tb-menuGroup.open>.tb-drop{display:block} -#hugagent-titlebar .tb-item{display:flex;align-items:center;width:100%;min-height:30px;padding:6px 11px;border:0;border-radius:6px;background:transparent;color:#30343B;font:13px/1.3 inherit;text-align:left;white-space:nowrap;cursor:default} -#hugagent-titlebar .tb-item:hover{background:#EEF4FF;color:#126DFF} -#hugagent-titlebar .tb-sep{height:1px;margin:5px 6px;background:#EDF0F4} +#hugagent-titlebar .tb-item{display:flex;align-items:center;width:100%;min-height:30px;padding:6px 11px;border:0;border-radius:6px;background:transparent;color:var(--color-text);font:13px/1.3 inherit;text-align:left;white-space:nowrap;cursor:default} +#hugagent-titlebar .tb-item:hover{background:var(--color-primary-light);color:var(--color-primary)} +#hugagent-titlebar .tb-sep{height:1px;margin:5px 6px;background:var(--color-border)} #hugagent-titlebar .tb-controls{display:flex;align-items:stretch;height:100%;margin-left:4px} -#hugagent-titlebar .tb-windowButton{width:46px;height:100%;padding:0;border:0;background:transparent;color:#41464C;display:flex;align-items:center;justify-content:center;cursor:default} -#hugagent-titlebar .tb-windowButton:hover{background:#E3E7ED} +#hugagent-titlebar .tb-windowButton{width:46px;height:100%;padding:0;border:0;background:transparent;color:var(--color-text-secondary);display:flex;align-items:center;justify-content:center;cursor:default} +#hugagent-titlebar .tb-windowButton:hover{background:var(--color-fill-hover)} +/* dark-ok: #E81123 是 Windows 关闭键的平台约定红,两档都得是这个红,不跟主题翻转 */ #hugagent-titlebar .tb-windowButton.close:hover{background:#E81123;color:#fff} "##; @@ -570,6 +592,42 @@ fn platform_titlebar_block(spa: bool) -> String { } } +/* 壳页面(登录 / 首启 / 部署 / 关闭确认 / 服务器地址)是**各自独立的文档**, + 拿不到 SPA 那份 ``,所以每张都要自己把主题落一遍。 + + 规则与 `src/frontend/index.html` 的防闪烁脚本逐字同源:同一个 localStorage key、 + 同一套解析(不是 light/dark 的值一律按 system 走系统外观)。壳页面由本地反代提供, + 与 SPA **同源**,因此读得到同一份偏好——用户手动选了深色而系统是浅色时它们也跟着深。 + 这正是不能用 `@media (prefers-color-scheme:dark)` 的原因:那只认系统,会和手动三档打架 + (前端门禁把它列为违规也是这个道理)。 + + Web 端那条「分享预览锁浅色」的分支是浏览器专有,壳页面没有分享场景,故不带。 + index.html / ce overlay 的 index.html 改了解析规则,这里要一起改。 */ +const THEME_BOOT_JS: &str = r##""##; + +/// 把主题引导脚本插进 `` 最前面——必须**早于任何样式**执行,否则深色用户会先看到 +/// 一帧白底再翻黑。 +fn with_theme_boot(html: &str) -> String { + const HEAD: &str = ""; + match html.find(HEAD) { + Some(index) => { + let at = index + HEAD.len(); + let mut output = String::with_capacity(html.len() + THEME_BOOT_JS.len()); + output.push_str(&html[..at]); + output.push_str(THEME_BOOT_JS); + output.push_str(&html[at..]); + output + } + None => format!("{THEME_BOOT_JS}{html}"), + } +} + fn inject_after_body(html: &str, block: &str) -> String { match html.find(" 登录 · HugAgentOS @@ -976,15 +1074,26 @@ const CLOSE_CONFIRM_HTML: &str = r##" 关闭 · HugAgentOS @@ -1025,15 +1135,26 @@ const SERVER_CONFIG_HTML: &str = r##" 服务器地址 · HugAgentOS @@ -1111,12 +1233,69 @@ mod tests { assert!(!block.contains("data-win=\"close\"")); assert!(!block.contains("tb-menuLabel")); assert!(block.contains("--hugagent-desktop-sidebar-width")); - assert!(block.contains("linear-gradient(90deg,rgba(203,223,255,.38)")); - assert!(block.contains("#F5F6F7!important")); + // 左半幅必须逐字复刻 sidebar.css 里 .jx-sider 的配方,否则安全区和侧边栏会脱色; + // 底色引令牌而不是写死,深色档才不会被 !important 摁回浅色。 + assert!(block.contains( + "linear-gradient(90deg,color-mix(in srgb, var(--color-bg-gray) 72%, transparent)" + )); + assert!(block.contains("var(--color-bg-layout)!important")); assert!(block.contains(".jx-brandRow,.jx-miniRail{padding-top:0!important}")); assert!(block.contains("ResizeObserver")); } + /// 每张壳页面都必须成对具备:`` 里的主题引导脚本 + `:root[data-theme="dark"]` 覆盖块。 + /// 少了脚本 → 深色偏好读不到,页面恒亮;少了覆盖块 → 属性打上了也没有对应样式。 + #[test] + fn every_shell_page_boots_and_defines_both_themes() { + for (name, html) in [ + ("login", LOGIN_HTML), + ("init", INIT_HTML), + ("setup", SETUP_HTML), + ("close-confirm", CLOSE_CONFIRM_HTML), + ("server-config", SERVER_CONFIG_HTML), + ] { + assert!( + html.contains(":root[data-theme=\"dark\"]"), + "{name} 页缺少深色覆盖块" + ); + let booted = with_theme_boot(html); + assert!(booted.contains("hugagent_theme_mode"), "{name} 页没注入主题引导"); + // 引导必须早于
正在准备更新…