diff --git a/CLAUDE.md b/CLAUDE.md index 8a534ba9..17919fd8 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -637,6 +637,20 @@ SidePanel 折叠 UI:`ChatView` 把 `SidePanel` 包到 `
` 标识,允许小写 ASCII 字母、数字及短横 草稿存放在 `themes/drafts`;安装后的文件位于 `themes///`,通过原子更新 `installed.json` 发布;新 ZIP 位于 `themes/exports//.zip`。旧版平铺 ZIP 在验证包内主题 ID、版本和内容后仍可复用,删除也按实际归属核验,避免 ID 与版本拼接相似时误删其它主题。确认绑定配色及图片哈希,修改内容后必须重新确认。工具和列表字段详见 [Daemon API](daemon-api.md#ai-theme-workflow-tool)。 +## HUD 外观与多风格 + +主题包可以在顶层(与 `colors`、`appearance` 同级)声明两个可选字段: + +```json +"chrome": {"style": "hud"}, +"variants": [ + {"id": "red", "name": "荒坂", "colors": {"…": "完整 28 色"}}, + {"id": "cyan", "name": "公共区", "colors": {"…": "完整 28 色"}, "tint": "#22CCF2", "appearance": {"logo_color": "#2BD3F5"}}, + {"id": "yellow", "name": "限制区", "colors": {"…": "完整 28 色"}, "tint": "#F2E022"} +] +``` + +- `chrome.style` 目前只有 `hud`。切角面板、霓虹描边、侧栏刻度、会话标尺、带凸台的输入框、代码块标题条和语法配色都由 ACECode 自带的样式(`web/src/styles/themeHud.css`)实现,颜色取自当前配色:线条用 `accent`,选中态与发送按钮用 `send-bg`。主题包仍然只含数据,不含 CSS、HTML 或脚本;不认识的 style 直接忽略,按普通主题显示。 +- `variants` 为 2–6 项,全部合法才生效,任意一项有问题就整组忽略,避免悄悄改变默认项。第一项是默认风格,应与顶层 `colors` 一致;每项都带完整 28 色。`appearance` 按键覆盖顶层外观,`tint` 让三张背景图按 CSS `color` 混合染色:保留图片明暗,换成 tint 的色相,所以一套图片就能对应全部风格。不带 tint 的风格按原图显示。 +- 染色作用于整块背景区域,包括用户消息气泡里图片之外的底色。只要图片顶部纯色与 `user_message_background_color` 亮度一致,长消息就不会出现接缝。 +- 兼容性:两个字段都在顶层,旧版 ACECode 和已部署的工坊校验都会忽略它们,旧版显示为默认风格的普通主题;`appearance` 没有新增键。 +- 设置 → 外观:当前主题带风格时显示「主题风格」,声明 HUD 时显示「动态效果」开关。选择保存在 `config.json` 的 `web_ui.theme_variant` / `web_ui.theme_effects`(见 [Daemon API](daemon-api.md#get-apiconfigui-preferences)),Desktop 首帧注入,换端口不会丢。 +- 动效只在 HUD 主题且「动态效果」开启时运行: + - 主题生效时的一次开屏扫描; + - 切换风格时的故障横条和三角警告过场,显示在设置窗口之上; + - 缓慢下移的扫描光带; + - 会话水印与首页壁纸的霓虹闪烁; + - 首页图标外的瞄准环转动; + - 选中任务的呼吸光; + - 悬停时的抖动; + - 输入框聚焦时沿底边的流光,Agent 运行中流光加速。 + + 动画只用 transform、opacity 或小面积重绘,不跟随系统的「减少动态效果」设置(远程桌面关闭窗口动画时会误报),以设置里的开关为准。 + ## Built-in National Day theme The built-in `national-day-2026` theme is the approved ACECode-created diff --git a/src/apps/desktop/main.cpp b/src/apps/desktop/main.cpp index 33d1d5a4..c0b8a876 100644 --- a/src/apps/desktop/main.cpp +++ b/src/apps/desktop/main.cpp @@ -2272,6 +2272,8 @@ int main(int argc, char** argv) { {"font_size", desktop_cfg.web_ui.font_size}, {"sidebar_session_time", desktop_cfg.web_ui.sidebar_session_time}, {"message_auto_collapse", desktop_cfg.web_ui.message_auto_collapse}, + {"theme_variant", desktop_cfg.web_ui.theme_variant}, + {"theme_effects", desktop_cfg.web_ui.theme_effects}, }.dump(); const std::string startup_bootstrap = startup_timeline.snapshot_json(); host.init_script(acecode::desktop::locale_bootstrap_script( diff --git a/src/apps/web/routes/routes_misc.cpp b/src/apps/web/routes/routes_misc.cpp index e0288426..b372802c 100644 --- a/src/apps/web/routes/routes_misc.cpp +++ b/src/apps/web/routes/routes_misc.cpp @@ -2194,6 +2194,22 @@ void WebServer::Impl::register_ui_preferences() { "sidebar_session_time must be a boolean"); } } + if (body.contains("theme_variant")) { + has_supported_field = true; + if (!body["theme_variant"].is_string() || + !is_valid_web_ui_theme_variant( + body["theme_variant"].get())) { + return json_err(400, "BAD_REQUEST", + "theme_variant must be empty or a lowercase variant ID"); + } + } + if (body.contains("theme_effects")) { + has_supported_field = true; + if (!body["theme_effects"].is_boolean()) { + return json_err(400, "BAD_REQUEST", + "theme_effects must be a boolean"); + } + } if (!has_supported_field) { return json_err(400, "BAD_REQUEST", "no supported UI preference field was provided"); @@ -2234,6 +2250,14 @@ void WebServer::Impl::register_ui_preferences() { deps.app_config->web_ui.sidebar_session_time = body["sidebar_session_time"].get(); } + if (body.contains("theme_variant")) { + deps.app_config->web_ui.theme_variant = + body["theme_variant"].get(); + } + if (body.contains("theme_effects")) { + deps.app_config->web_ui.theme_effects = + body["theme_effects"].get(); + } try { if (!deps.config_path.empty()) { save_config(*deps.app_config, deps.config_path); diff --git a/src/apps/web/server_helpers.cpp b/src/apps/web/server_helpers.cpp index a51b8938..233b233c 100644 --- a/src/apps/web/server_helpers.cpp +++ b/src/apps/web/server_helpers.cpp @@ -101,6 +101,8 @@ json ui_preferences_to_json(const WebUiPreferencesConfig& cfg) { {"font_size", cfg.font_size}, {"sidebar_session_time", cfg.sidebar_session_time}, {"message_auto_collapse", cfg.message_auto_collapse}, + {"theme_variant", cfg.theme_variant}, + {"theme_effects", cfg.theme_effects}, }; } diff --git a/src/base/config/config.cpp b/src/base/config/config.cpp index 4f28ae37..035d5fce 100644 --- a/src/base/config/config.cpp +++ b/src/base/config/config.cpp @@ -229,6 +229,18 @@ bool is_valid_web_ui_font_size(const std::string& font_size) { font_size == "large"; } +bool is_valid_web_ui_theme_variant(const std::string& variant) { + if (variant.empty()) return true; + if (variant.size() > 24 || variant.front() == '-' || variant.back() == '-') return false; + char previous = 0; + for (const char c : variant) { + const bool word = (c >= 'a' && c <= 'z') || (c >= '0' && c <= '9'); + if (!word && (c != '-' || previous == '-')) return false; + previous = c; + } + return true; +} + nlohmann::json connectors_to_json(const std::vector& connectors) { nlohmann::json items = nlohmann::json::array(); for (const auto& connector : connectors) { @@ -500,6 +512,9 @@ std::vector validate_config(const AppConfig& cfg) { if (!is_valid_web_ui_font_size(cfg.web_ui.font_size)) { errors.push_back("web_ui.font_size must be one of: small, medium, large"); } + if (!is_valid_web_ui_theme_variant(cfg.web_ui.theme_variant)) { + errors.push_back("web_ui.theme_variant must be empty or a lowercase variant ID"); + } if (cfg.web.remote_port < 0 || cfg.web.remote_port > 65535) { errors.push_back( "web.remote_port out of range (0-65535): " + @@ -1084,6 +1099,24 @@ static AppConfig load_config_from_path_once( LOG_WARN("[config] invalid 'web_ui.sidebar_session_time', using true"); } } + if (uij.contains("theme_variant")) { + if (uij["theme_variant"].is_string() && + is_valid_web_ui_theme_variant( + uij["theme_variant"].get())) { + cfg.web_ui.theme_variant = + uij["theme_variant"].get(); + } else { + LOG_WARN("[config] invalid 'web_ui.theme_variant', using the theme default"); + } + } + if (uij.contains("theme_effects")) { + if (uij["theme_effects"].is_boolean()) { + cfg.web_ui.theme_effects = + uij["theme_effects"].get(); + } else { + LOG_WARN("[config] invalid 'web_ui.theme_effects', using true"); + } + } } } if (j.contains("models_dev") && j["models_dev"].is_object()) { @@ -2330,6 +2363,10 @@ nlohmann::json build_config_json(const AppConfig& cfg) { web_uij["sidebar_session_time"] = cfg.web_ui.sidebar_session_time; if (cfg.web_ui.message_auto_collapse != web_ui_d.message_auto_collapse) web_uij["message_auto_collapse"] = cfg.web_ui.message_auto_collapse; + if (cfg.web_ui.theme_variant != web_ui_d.theme_variant) + web_uij["theme_variant"] = cfg.web_ui.theme_variant; + if (cfg.web_ui.theme_effects != web_ui_d.theme_effects) + web_uij["theme_effects"] = cfg.web_ui.theme_effects; if (!web_uij.empty()) j["web_ui"] = std::move(web_uij); if (auto memj = memory_config_to_json(cfg.memory); !memj.empty()) j["memory"] = std::move(memj); diff --git a/src/base/config/config.hpp b/src/base/config/config.hpp index 29fc269a..77020244 100644 --- a/src/base/config/config.hpp +++ b/src/base/config/config.hpp @@ -221,6 +221,11 @@ struct WebUiPreferencesConfig { bool sidebar_session_time = true; // Collapse conversation activity; false keeps only individual tools foldable. bool message_auto_collapse = true; + // Variant ID inside the selected theme package (empty = its first variant). + // The renderer falls back to the default when the active theme lacks it. + std::string theme_variant; + // Motion for themes that declare an animated chrome style; default on. + bool theme_effects = true; }; struct ModelsDevConfig { @@ -687,6 +692,8 @@ bool is_valid_ui_locale(const std::string& locale); bool is_valid_web_ui_theme(const std::string& theme); bool is_valid_web_ui_color_theme(const std::string& color_theme); bool is_valid_web_ui_font_size(const std::string& font_size); +// Empty, or 1-24 chars of lowercase ASCII words joined by single hyphens. +bool is_valid_web_ui_theme_variant(const std::string& variant); nlohmann::json connectors_to_json(const std::vector& connectors); bool parse_connectors_json(const nlohmann::json& value, diff --git a/tests/config/config_web_ui_preferences_test.cpp b/tests/config/config_web_ui_preferences_test.cpp index 848ef6e2..bae89610 100644 --- a/tests/config/config_web_ui_preferences_test.cpp +++ b/tests/config/config_web_ui_preferences_test.cpp @@ -292,6 +292,78 @@ TEST(ConfigWebUiPreferencesMessageAutoCollapse, DisabledRoundTripsAndDefaultIsOm std::filesystem::remove(path, ec); } +// 场景:主题风格 ID 的格式校验(配置文件、REST 与前端共用同一规则)。 +// 期望:空串表示主题默认风格;只接受 1-24 位小写字母数字、单个连字符分隔的词。 +// 大写、首尾或连续连字符、超长、非 ASCII 都拒绝——它会直接写进 HTML 属性。 +TEST(ConfigWebUiPreferencesThemeVariant, ValidatorAcceptsOnlyLowercaseSlugs) { + EXPECT_TRUE(is_valid_web_ui_theme_variant("")); + EXPECT_TRUE(is_valid_web_ui_theme_variant("red")); + EXPECT_TRUE(is_valid_web_ui_theme_variant("cyan-2")); + EXPECT_TRUE(is_valid_web_ui_theme_variant(std::string(24, 'a'))); + EXPECT_FALSE(is_valid_web_ui_theme_variant(std::string(25, 'a'))); + // "\xE7\xBA\xA2" 是 UTF-8 的「红」:非 ASCII 风格名同样拒绝。 + for (const char* bad : {"Red", "-red", "red-", "red--cyan", "red cyan", "red_cyan", "\xE7\xBA\xA2", "red\n"}) { + EXPECT_FALSE(is_valid_web_ui_theme_variant(bad)) << bad; + } +} + +// 场景:旧配置没有新字段 / 新字段合法 / 新字段被手改坏。 +// 期望:缺省为默认风格 + 动效开启;合法值读入;坏值逐项回退默认并只记警告, +// 不能连带其它外观字段一起失效(外观整体判失败会把主题回退成蓝色)。 +TEST(ConfigWebUiPreferencesThemeVariant, LoaderReadsValidValuesAndIgnoresInvalidOnes) { + { + const AppConfig defaults; + EXPECT_EQ(defaults.web_ui.theme_variant, ""); + EXPECT_TRUE(defaults.web_ui.theme_effects); + } + const auto path = temp_config_path("theme-variant"); + write_json(path, {{"web_ui", {{"color_theme", "orange"}, {"theme_variant", "cyan"}, {"theme_effects", false}}}}); + { + const AppConfig cfg = load_config_from_path(path.string()); + EXPECT_EQ(cfg.web_ui.theme_variant, "cyan"); + EXPECT_FALSE(cfg.web_ui.theme_effects); + EXPECT_EQ(cfg.web_ui.color_theme, "orange"); + } + write_json(path, {{"web_ui", {{"color_theme", "orange"}, {"theme_variant", "Cyan"}, {"theme_effects", "off"}}}}); + { + const AppConfig cfg = load_config_from_path(path.string()); + EXPECT_EQ(cfg.web_ui.theme_variant, ""); + EXPECT_TRUE(cfg.web_ui.theme_effects); + EXPECT_EQ(cfg.web_ui.color_theme, "orange"); + } + std::error_code ec; + std::filesystem::remove(path, ec); +} + +// 场景:保存配置时风格与动效保持稀疏写盘。 +// 期望:默认值(空风格、动效开)不出现在 config.json;非默认值写盘后能原样读回, +// 切回默认后整个 web_ui 段再次消失。 +TEST(ConfigWebUiPreferencesThemeVariant, SaveIsSparseAndRoundTrips) { + const auto path = temp_config_path("theme-variant-save"); + AppConfig cfg; + cfg.web_ui.theme_variant = "yellow"; + cfg.web_ui.theme_effects = false; + save_config(cfg, path.string()); + { + std::ifstream input(path); + const auto saved = nlohmann::json::parse(input); + EXPECT_EQ(saved["web_ui"]["theme_variant"], "yellow"); + EXPECT_EQ(saved["web_ui"]["theme_effects"], false); + } + const auto loaded = load_config_from_path(path.string()); + EXPECT_EQ(loaded.web_ui.theme_variant, "yellow"); + EXPECT_FALSE(loaded.web_ui.theme_effects); + cfg.web_ui.theme_variant.clear(); + cfg.web_ui.theme_effects = true; + save_config(cfg, path.string()); + { + std::ifstream input(path); + EXPECT_FALSE(nlohmann::json::parse(input).contains("web_ui")); + } + std::error_code ec; + std::filesystem::remove(path, ec); +} + TEST(ConfigWebUiPreferencesSave, DownloadedThemePreservesOrdinaryDarkPreference) { const auto path = temp_config_path("eva-theme"); AppConfig cfg; diff --git a/tests/web/web_server_smoke_test.cpp b/tests/web/web_server_smoke_test.cpp index 84894f25..3d9c61ea 100644 --- a/tests/web/web_server_smoke_test.cpp +++ b/tests/web/web_server_smoke_test.cpp @@ -9095,6 +9095,39 @@ TEST(WebServerHttp, UiPreferencesMessageAutoCollapseRoundTrips) { EXPECT_TRUE(fx.cfg.web_ui.message_auto_collapse); } +// 场景:设置页切换主题风格(红/青/黄)与动态效果开关。 +// 期望:GET 默认返回空风格 + 动效开;单字段 PUT 只改自己且落盘,其它外观值不动; +// 格式不合法的风格 ID 或非布尔动效值整笔 400,内存配置保持原值。 +TEST(WebServerHttp, UiPreferencesThemeVariantAndEffectsRoundTrip) { + WebServerFixture fx; + const auto url = cpr::Url{fx.url("/api/config/ui-preferences")}; + const cpr::Header headers{{"Content-Type", "application/json"}}; + { + const auto initial = json::parse(cpr::Get(url).text); + EXPECT_EQ(initial["theme_variant"], ""); + EXPECT_EQ(initial["theme_effects"], true); + } + auto variant = cpr::Put(url, headers, cpr::Body{R"({"theme_variant":"cyan"})"}); + ASSERT_EQ(variant.status_code, 200) << variant.text; + EXPECT_EQ(json::parse(variant.text)["theme_variant"], "cyan"); + EXPECT_EQ(fx.cfg.web_ui.theme_variant, "cyan"); + EXPECT_EQ(fx.cfg.web_ui.font_size, "medium"); + auto effects = cpr::Put(url, headers, cpr::Body{R"({"theme_effects":false})"}); + ASSERT_EQ(effects.status_code, 200) << effects.text; + EXPECT_EQ(json::parse(effects.text)["theme_variant"], "cyan"); + EXPECT_EQ(json::parse(effects.text)["theme_effects"], false); + const auto saved = acecode::load_config_from_path((fx.tmp_dir / "config.json").u8string()); + EXPECT_EQ(saved.web_ui.theme_variant, "cyan"); + EXPECT_FALSE(saved.web_ui.theme_effects); + for (const char* body : {R"({"theme_variant":"Cyan"})", R"({"theme_variant":"a--b"})", + R"({"theme_variant":7})", R"({"theme_effects":"on","theme_variant":"red"})"}) { + auto rejected = cpr::Put(url, headers, cpr::Body{body}); + EXPECT_EQ(rejected.status_code, 400) << body << " " << rejected.text; + } + EXPECT_EQ(fx.cfg.web_ui.theme_variant, "cyan"); + EXPECT_FALSE(fx.cfg.web_ui.theme_effects); +} + TEST(WebServerHttp, UiPreferencesMessageAutoCollapseRollsBackOnSaveFailure) { WebServerFixture fx; std::filesystem::create_directories(fx.tmp_dir / "config.json"); diff --git a/web/scripts/i18n-en-overrides.mjs b/web/scripts/i18n-en-overrides.mjs index b1b77ffe..5266576e 100644 --- a/web/scripts/i18n-en-overrides.mjs +++ b/web/scripts/i18n-en-overrides.mjs @@ -1,6 +1,12 @@ // Human-reviewed product terminology and phrases where generic machine // translation is ambiguous without ACECode context. export const ENGLISH_SOURCE_OVERRIDES = Object.freeze({ + // Settings > Appearance: variants and motion for themes with the HUD chrome. + '主题风格': 'Theme style', + '切换当前主题的配色风格': 'Switch the color style of the current theme', + '动态效果': 'Motion effects', + '播放开屏扫描、风格切换过场和霓虹闪烁等主题动画': 'Play theme animations such as the startup scan, style-switch transition and neon flicker', + '主题动态效果': 'Theme motion effects', // Conversation find (Ctrl+F) when a very long session is only partly loaded. '只搜索了最近 {{p0}} 条消息': 'Searched only the latest {{p0}} messages', '在文件列表展示': 'Show in file list', diff --git a/web/src/App.jsx b/web/src/App.jsx index 6c1feb71..f0ae2343 100644 --- a/web/src/App.jsx +++ b/web/src/App.jsx @@ -250,6 +250,10 @@ export function App() { prepareTheme, forgetTheme, themeMode, + themeVariant, + themeEffects, + setThemeVariant, + setThemeEffects, } = useTheme(); const initialAppearance = useMemo(() => initialAppearancePreferences(), []); const bootstrapAppearance = useMemo(() => appearanceBootstrapPreferences(), []); @@ -407,12 +411,14 @@ export function App() { const applyAppearance = useCallback((next) => { setTheme(next.theme); setColorTheme(next.colorTheme); + setThemeVariant(next.themeVariant); + setThemeEffects(next.themeEffects); setUiPrefs({ fontSize: next.fontSize, sidebarSessionTime: next.sidebarSessionTime, messageAutoCollapse: next.messageAutoCollapse, }); - }, [setColorTheme, setTheme, setUiPrefs]); + }, [setColorTheme, setTheme, setThemeEffects, setThemeVariant, setUiPrefs]); // The persistence controller outlives individual renders. Keep its callback // current so a later appearance choice is compared against the latest UI // preferences rather than the values from the controller's first render. @@ -427,6 +433,8 @@ export function App() { fontSize, sidebarSessionTime, messageAutoCollapse: bootstrapAppearance?.messageAutoCollapse ?? messageAutoCollapse, + themeVariant: bootstrapAppearance?.themeVariant ?? themeVariant, + themeEffects: bootstrapAppearance?.themeEffects ?? themeEffects, }, apply: (next) => applyAppearanceRef.current(next), save: (payload) => api.setUiPreferences(payload), @@ -2519,6 +2527,8 @@ export function App() { onSidebarSessionTimeChange={(next) => changeAppearance({ sidebarSessionTime: next })} messageAutoCollapse={messageAutoCollapse} onMessageAutoCollapseChange={(next) => changeAppearance({ messageAutoCollapse: next })} + onThemeVariantChange={(next) => changeAppearance({ themeVariant: next })} + onThemeEffectsChange={(next) => changeAppearance({ themeEffects: next })} /> )} themeDownloads.controller.dismissFailure()} /> diff --git a/web/src/components/HudEffects.jsx b/web/src/components/HudEffects.jsx new file mode 100644 index 00000000..49a2b7f8 --- /dev/null +++ b/web/src/components/HudEffects.jsx @@ -0,0 +1,73 @@ +// Motion layer for themes with the HUD chrome: a slow scan band, a one-shot +// boot sweep when the theme becomes active, and a glitch interstitial when the +// user switches variants. Purely decorative: aria-hidden, no pointer events, +// unmounted entirely when the theme's effects switch is off. + +import { useEffect, useRef, useState } from 'react'; +import { hudBurstForChange, hudVariantLabel } from '../lib/hudEffects.js'; +import '../styles/themeHud.css'; + +const BURST_MS = { boot: 1400, switch: 1100 }; + +// Rendered by ThemeProvider with its resolved theme state (props, not +// useTheme(), so the provider module does not import itself back). +export function HudEffects({ chrome, effects, colorTheme, variant, variantOptions }) { + const enabled = chrome === 'hud' && effects !== false; + const [burst, setBurst] = useState(null); + const previous = useRef(null); + const bootAt = useRef(0); + + useEffect(() => { + const current = { colorTheme, variant }; + const now = Date.now(); + const kind = enabled ? hudBurstForChange(previous.current, current, now - bootAt.current) : null; + previous.current = enabled ? current : null; + if (!kind) return; + if (kind === 'boot') bootAt.current = now; + setBurst({ kind, key: `${kind}:${now}` }); + }, [enabled, colorTheme, variant]); + + // Each burst owns its own timer, so a later theme update cannot cancel the + // cleanup of an overlay that is already playing. + useEffect(() => { + if (!burst) return undefined; + const timer = setTimeout(() => setBurst(null), BURST_MS[burst.kind]); + return () => clearTimeout(timer); + }, [burst]); + + if (!enabled) return null; + const label = hudVariantLabel(variantOptions, variant); + // The sweep stays under dialogs; bursts sit above them so a variant switch + // made from Settings still shows its transition. + return ( + <> +