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` 包到 `
diff --git a/web/src/i18n/sourceCatalog.generated.js b/web/src/i18n/sourceCatalog.generated.js
index 318c29d4..605fea72 100644
--- a/web/src/i18n/sourceCatalog.generated.js
+++ b/web/src/i18n/sourceCatalog.generated.js
@@ -299,6 +299,7 @@ export const sourceCatalogs = {
"s_1847a84db6d5fc1a": "权限模式切换失败:",
"s_18565a5455a0a875": "加载沙箱设置失败",
"s_18578640315b5b33": "更新待办",
+ "s_18704a278632121e": "动态效果",
"s_1878849ddfed4665": "speech voice dictation microphone sensevoice whisper 录音 听写 麦克风 内网 地址 端口",
"s_1882a447d5f76d5f": "反馈内容不能超过 10000 字",
"s_188397c445996182": "已由另一个 ACECode 连接",
@@ -688,6 +689,7 @@ export const sourceCatalogs = {
"s_3ac23fa27c7b0894": "留空的参数继承模型设置;发起子任务时可单独覆盖。",
"s_3ae030dd220465c9": "工作区用量",
"s_3afa87e94673b0d1": "派遣失败:{{p0}}",
+ "s_3b002388661e94cb": "切换当前主题的配色风格",
"s_3b6fefc50febace4": "小时",
"s_3b7e27cb4379da3c": "正在处理",
"s_3b8f1538e0895e4d": "简要说明什么时候适合派遣这个组件",
@@ -901,6 +903,7 @@ export const sourceCatalogs = {
"s_4d2b19065afe4b07": "LSP 已开启:{{p0}}",
"s_4d3f6a081ea86097": "彻底删除失败:",
"s_4d5193ca941bf930": "下载中 {{p0}}%",
+ "s_4d5262c1b9621225": "播放开屏扫描、风格切换过场和霓虹闪烁等主题动画",
"s_4d6a4bc105dd3f07": "共享",
"s_4d6b5440965402ae": "通过右上角操作开始创建。",
"s_4d7611fc1530dbaa": "不可用警告",
@@ -1821,6 +1824,7 @@ export const sourceCatalogs = {
"s_9c8eb75c7e58b497": "策略",
"s_9c9154bd75202a5c": "需指定路径",
"s_9c97a750841e6656": "用一段话介绍能够解决的问题和交付方式",
+ "s_9ceecca066a94968": "主题动态效果",
"s_9d028438fbd04725": "请先填写 API 地址和 Key,或选择可用的模型连接",
"s_9d0f70c4cee1d679": "本次会话放行",
"s_9d1a1b1c006d85f0": "文件编码不受支持,只能只读预览",
@@ -2610,6 +2614,7 @@ export const sourceCatalogs = {
"s_e2469247115761e9": "默认画质",
"s_e26740162fbcb387": "将排队消息插入当前回合",
"s_e2780ee23078ea51": "{{p0}}{{p1}}请求使用 ACECode,到设置 > 消息通道处理",
+ "s_e2a17a075548001b": "主题风格",
"s_e2a76ef1f12e147f": "终端",
"s_e2ad5101dbde2f62": "展开/收起左侧项目栏",
"s_e2f12da7faa68217": "已下载",
@@ -3257,6 +3262,7 @@ export const sourceCatalogs = {
"s_1847a84db6d5fc1a": "Permission mode switch failed:",
"s_18565a5455a0a875": "Could not load sandbox settings",
"s_18578640315b5b33": "Updating todos",
+ "s_18704a278632121e": "Motion effects",
"s_1878849ddfed4665": "speech voice dictation microphone sensevoice whisper recording LAN URL port",
"s_1882a447d5f76d5f": "Feedback cannot exceed 10,000 characters",
"s_188397c445996182": "Connected by another ACECode",
@@ -3646,6 +3652,7 @@ export const sourceCatalogs = {
"s_3ac23fa27c7b0894": "Blank fields inherit model settings. Each subtask can override them when started.",
"s_3ae030dd220465c9": "Work area usage",
"s_3afa87e94673b0d1": "Dispatch failed: {{p0}}",
+ "s_3b002388661e94cb": "Switch the color style of the current theme",
"s_3b6fefc50febace4": "Hour",
"s_3b7e27cb4379da3c": "Working",
"s_3b8f1538e0895e4d": "Briefly describe when it is appropriate to dispatch this component",
@@ -3859,6 +3866,7 @@ export const sourceCatalogs = {
"s_4d2b19065afe4b07": "LSP is on:{{p0}}",
"s_4d3f6a081ea86097": "Complete deletion failed:",
"s_4d5193ca941bf930": "Downloading {{p0}}%",
+ "s_4d5262c1b9621225": "Play theme animations such as the startup scan, style-switch transition and neon flicker",
"s_4d6a4bc105dd3f07": "Shared",
"s_4d6b5440965402ae": "Use the actions in the upper-right corner to get started.",
"s_4d7611fc1530dbaa": "Availability warning",
@@ -4779,6 +4787,7 @@ export const sourceCatalogs = {
"s_9c8eb75c7e58b497": "Policies",
"s_9c9154bd75202a5c": "Need to specify path",
"s_9c97a750841e6656": "Use a paragraph to introduce the problem that can be solved and the delivery method",
+ "s_9ceecca066a94968": "Theme motion effects",
"s_9d028438fbd04725": "Enter an API URL and key, or choose an available model connection",
"s_9d0f70c4cee1d679": "Allowed for this session",
"s_9d1a1b1c006d85f0": "Unsupported file encoding; read-only preview only",
@@ -5568,6 +5577,7 @@ export const sourceCatalogs = {
"s_e2469247115761e9": "Default quality",
"s_e26740162fbcb387": "Interject queued message into the active turn",
"s_e2780ee23078ea51": "{{p0}}{{p1}} wants to use ACECode. Review it in Settings > Messaging channels",
+ "s_e2a17a075548001b": "Theme style",
"s_e2a76ef1f12e147f": "Terminal",
"s_e2ad5101dbde2f62": "Toggle project sidebar",
"s_e2f12da7faa68217": "Downloaded",
diff --git a/web/src/lib/appearancePreferences.js b/web/src/lib/appearancePreferences.js
index dcc99450..c3a2703f 100644
--- a/web/src/lib/appearancePreferences.js
+++ b/web/src/lib/appearancePreferences.js
@@ -15,6 +15,8 @@ export const DEFAULT_APPEARANCE_PREFERENCES = Object.freeze({
fontSize: DEFAULT_FONT_SIZE,
sidebarSessionTime: true,
messageAutoCollapse: true,
+ themeVariant: '',
+ themeEffects: true,
});
const FONT_SIZE_SET = new Set(FONT_SIZE_VALUES);
@@ -49,6 +51,24 @@ function inputMessageAutoCollapse(value) {
return (value?.message_auto_collapse ?? value?.messageAutoCollapse) !== false;
}
+// Variant IDs come from the selected theme package; an empty string means its
+// first (default) variant. Unknown IDs are kept and resolved by the renderer.
+export function validThemeVariantId(value) {
+ return value === '' || (typeof value === 'string' && value.length <= 24
+ && /^[a-z0-9]+(?:-[a-z0-9]+)*$/.test(value));
+}
+
+function inputThemeVariant(value) {
+ const variant = value?.theme_variant ?? value?.themeVariant;
+ return validThemeVariantId(variant) ? variant : '';
+}
+
+// Theme motion defaults on; only an explicit false turns it off. Older daemons
+// that do not return the key keep the default instead of failing the parse.
+function inputThemeEffects(value) {
+ return (value?.theme_effects ?? value?.themeEffects) !== false;
+}
+
export function effectiveAppearanceTheme(value, scope = globalThis) {
return value === 'light' || value === 'dark'
? value
@@ -71,6 +91,8 @@ export function normalizeAppearancePreferences(value, scope = globalThis) {
: DEFAULT_APPEARANCE_PREFERENCES.fontSize,
sidebarSessionTime: inputSidebarSessionTime(value),
messageAutoCollapse: inputMessageAutoCollapse(value),
+ themeVariant: inputThemeVariant(value),
+ themeEffects: inputThemeEffects(value),
};
}
@@ -89,6 +111,8 @@ export function parseAppearancePreferences(value, scope = globalThis) {
fontSize,
sidebarSessionTime: inputSidebarSessionTime(value),
messageAutoCollapse: inputMessageAutoCollapse(value),
+ themeVariant: inputThemeVariant(value),
+ themeEffects: inputThemeEffects(value),
};
}
@@ -112,6 +136,12 @@ export function mergeAppearancePreferences(current, patch, scope = globalThis) {
messageAutoCollapse: patch?.messageAutoCollapse
?? patch?.message_auto_collapse
?? current?.messageAutoCollapse,
+ themeVariant: patch?.themeVariant
+ ?? patch?.theme_variant
+ ?? current?.themeVariant,
+ themeEffects: patch?.themeEffects
+ ?? patch?.theme_effects
+ ?? current?.themeEffects,
}, scope);
}
@@ -124,6 +154,8 @@ export function appearancePreferencesToApi(value, scope = globalThis) {
font_size: normalized.fontSize,
sidebar_session_time: normalized.sidebarSessionTime,
message_auto_collapse: normalized.messageAutoCollapse,
+ theme_variant: normalized.themeVariant,
+ theme_effects: normalized.themeEffects,
};
}
diff --git a/web/src/lib/appearancePreferences.test.js b/web/src/lib/appearancePreferences.test.js
index 799fc0ec..cfd485a7 100644
--- a/web/src/lib/appearancePreferences.test.js
+++ b/web/src/lib/appearancePreferences.test.js
@@ -10,6 +10,9 @@ import {
systemThemeFallback,
} from './appearancePreferences.js';
+// 主题风格与动效开关的默认值:空风格 = 主题的第一个风格,动效默认开。
+const THEME_STYLE_DEFAULTS = Object.freeze({ themeVariant: '', themeEffects: true });
+
async function run(name, fn) {
try {
await fn();
@@ -22,7 +25,7 @@ async function run(name, fn) {
await run('failed automatic theme persistence silently restores the original new or upgraded appearance', async () => {
for (const colorTheme of ['blue', 'orange', 'ai-existing']) {
- const initial = { theme: 'system', colorTheme, fontSize: 'large', sidebarSessionTime: false, messageAutoCollapse: true };
+ const initial = { theme: 'system', colorTheme, fontSize: 'large', sidebarSessionTime: false, messageAutoCollapse: true, ...THEME_STYLE_DEFAULTS };
const applied = [], errors = [], saves = [];
const expected = appearancePreferencesToApi(initial);
const controller = createAppearancePersistenceController({ initial,
@@ -50,7 +53,7 @@ await run('appearance defaults preserve system preference, blue, and medium', ()
fontSize: 'medium',
// 侧栏时间是产品默认开的显示项:空输入必须归一成 true,否则升级用户
// 一进来就发现时间列没了。
- sidebarSessionTime: true, messageAutoCollapse: true,
+ sidebarSessionTime: true, messageAutoCollapse: true, ...THEME_STYLE_DEFAULTS,
});
// 只有显式 false 才关;缺键、null、非布尔值都按开处理,免得旧 daemon 的
// 半截报文把这一列意外关掉。
@@ -77,13 +80,13 @@ await run('desktop bootstrap is normalized before first render', () => {
theme: 'light',
colorTheme: 'orange',
fontSize: 'large',
- sidebarSessionTime: true, messageAutoCollapse: true,
+ sidebarSessionTime: true, messageAutoCollapse: true, ...THEME_STYLE_DEFAULTS,
});
assert.deepEqual(appearanceBootstrapPreferences(scope), {
theme: 'light',
colorTheme: 'orange',
fontSize: 'large',
- sidebarSessionTime: true, messageAutoCollapse: true,
+ sidebarSessionTime: true, messageAutoCollapse: true, ...THEME_STYLE_DEFAULTS,
});
});
@@ -108,6 +111,8 @@ await run('API serialization sends a complete compatibility snapshot', () => {
font_size: 'small',
sidebar_session_time: true,
message_auto_collapse: true,
+ theme_variant: '',
+ theme_effects: true,
});
});
@@ -150,7 +155,7 @@ await run('canonical restore wins only before a local user mutation', async () =
theme: 'dark',
colorTheme: 'orange',
fontSize: 'small',
- sidebarSessionTime: true, messageAutoCollapse: true,
+ sidebarSessionTime: true, messageAutoCollapse: true, ...THEME_STYLE_DEFAULTS,
});
assert.equal(applied.length, 3);
});
@@ -171,7 +176,7 @@ await run('color and font changes preserve a canonical system theme preference',
theme: 'system',
colorTheme: 'orange',
fontSize: 'large',
- sidebarSessionTime: true, messageAutoCollapse: true,
+ sidebarSessionTime: true, messageAutoCollapse: true, ...THEME_STYLE_DEFAULTS,
});
});
@@ -186,8 +191,8 @@ await run('failed latest save rolls back to the last confirmed appearance', asyn
});
await controller.change({ theme: 'dark', colorTheme: 'orange' });
assert.deepEqual(applied, [
- { theme: 'dark', colorTheme: 'orange', fontSize: 'medium', sidebarSessionTime: true, messageAutoCollapse: true },
- { theme: 'light', colorTheme: 'blue', fontSize: 'medium', sidebarSessionTime: true, messageAutoCollapse: true },
+ { theme: 'dark', colorTheme: 'orange', fontSize: 'medium', sidebarSessionTime: true, messageAutoCollapse: true, ...THEME_STYLE_DEFAULTS },
+ { theme: 'light', colorTheme: 'blue', fontSize: 'medium', sidebarSessionTime: true, messageAutoCollapse: true, ...THEME_STYLE_DEFAULTS },
]);
assert.deepEqual(errors, ['disk full']);
});
@@ -216,13 +221,13 @@ await run('rapid changes serialize saves and keep the newest snapshot', async ()
theme: 'dark',
colorTheme: 'orange',
fontSize: 'medium',
- sidebarSessionTime: true, messageAutoCollapse: true,
+ sidebarSessionTime: true, messageAutoCollapse: true, ...THEME_STYLE_DEFAULTS,
});
});
const deferred = () => { let resolve; const promise = new Promise((r) => { resolve = r; }); return { promise, resolve }; };
const deletionTick = () => new Promise((resolve) => setImmediate(resolve));
-const localAppearance = { theme: 'dark', colorTheme: 'ai-night', fontSize: 'medium', sidebarSessionTime: true, messageAutoCollapse: true };
+const localAppearance = { theme: 'dark', colorTheme: 'ai-night', fontSize: 'medium', sidebarSessionTime: true, messageAutoCollapse: true, ...THEME_STYLE_DEFAULTS };
await run('消息自动折叠缺省开启,关闭值经启动注入、API、其它偏好修改后保持', async () => {
const canonical = { theme: 'system', color_theme: 'blue', font_size: 'medium', message_auto_collapse: false };
diff --git a/web/src/lib/colorThemeArchitecture.test.js b/web/src/lib/colorThemeArchitecture.test.js
index 95977e81..c204901e 100644
--- a/web/src/lib/colorThemeArchitecture.test.js
+++ b/web/src/lib/colorThemeArchitecture.test.js
@@ -54,9 +54,11 @@ run('ThemeProvider preserves the ordinary mode independently of installed themes
theme,
/COLOR_THEME_STORAGE_KEY,\s*initialAppearance\.colorTheme,\s*isValidColorTheme/,
);
+ // The original context fields stay first; theme variants/motion for HUD
+ // themes are appended after them.
assert.match(
theme,
- /value=\{\{ theme, themeMode, colorTheme, appearance, toggle, set, setColorTheme, prepareTheme, forgetTheme \}\}/,
+ /value=\{\{\s*theme, themeMode, colorTheme, appearance, toggle, set, setColorTheme, prepareTheme, forgetTheme,\s*themeVariant, themeEffects, setThemeVariant, setThemeEffects, themeChrome,\s*themeVariantOptions: variantOptions, activeThemeVariant,\s*\}\}/,
);
});
@@ -69,7 +71,7 @@ run('ThemeProvider applies both root attributes before native background sync',
assert.ok(modeAttribute >= 0);
assert.ok(colorAttribute > modeAttribute);
assert.ok(nativeSync > colorAttribute);
- assert.match(theme, /\}, \[colorTheme, theme, installedTheme\]\);/);
+ assert.match(theme, /\}, \[colorTheme, theme, installedTheme, themeVariant, themeEffects\]\);/);
});
run('Appearance settings separate color cards from the dark-mode toggle', () => {
diff --git a/web/src/lib/hudEffects.js b/web/src/lib/hudEffects.js
new file mode 100644
index 00000000..de59734b
--- /dev/null
+++ b/web/src/lib/hudEffects.js
@@ -0,0 +1,19 @@
+// Pure decisions for the HUD motion layer (components/HudEffects.jsx).
+
+// The daemon restores the saved variant shortly after the first paint. A
+// variant change this close to the boot sweep is that restore, not a user
+// switch, so it must not play a second animation on top of the first.
+export const HUD_SWITCH_GRACE_MS = 1500;
+
+// previous/current: { colorTheme, variant } snapshots while effects are on;
+// previous is null when the effects layer was off or not yet shown.
+export function hudBurstForChange(previous, current, elapsedSinceBoot = Infinity) {
+ if (!current) return null;
+ if (!previous || previous.colorTheme !== current.colorTheme) return 'boot';
+ if (previous.variant !== current.variant && elapsedSinceBoot >= HUD_SWITCH_GRACE_MS) return 'switch';
+ return null;
+}
+
+export function hudVariantLabel(options, variantId) {
+ return (Array.isArray(options) ? options : []).find((option) => option.id === variantId)?.name || '';
+}
diff --git a/web/src/lib/runTests.js b/web/src/lib/runTests.js
index bc8b4ba8..8573e40a 100644
--- a/web/src/lib/runTests.js
+++ b/web/src/lib/runTests.js
@@ -36,6 +36,7 @@ import './topBarQuickActions.test.js';
import './themePackages.test.js';
import './themeImports.test.js';
import './themeSurfaces.test.js';
+import './themeHud.test.js';
import './themeExports.test.js';
import './themeManagementUi.test.js';
import './aiThemeCreation.test.js';
diff --git a/web/src/lib/themeHud.test.js b/web/src/lib/themeHud.test.js
new file mode 100644
index 00000000..2165650d
--- /dev/null
+++ b/web/src/lib/themeHud.test.js
@@ -0,0 +1,227 @@
+// 覆盖主题包的 HUD 外观(chrome)与多风格(variants):数据校验、风格解析、背景染色、
+// 根节点属性、动效触发判断、外观偏好读写,以及 themeHud.css 只作用于 HUD 主题的约束。
+import assert from 'node:assert/strict';
+import { readFileSync } from 'node:fs';
+import {
+ THEME_COLOR_KEYS, applyInstalledTheme, resolveThemeAppearance, resolveThemeVariant, selectThemeVariant,
+ themeChromeStyle, themeCssProperties, themeVariantOptions, themeVariants, validThemeVariant,
+} from './themePackages.js';
+import { HUD_SWITCH_GRACE_MS, hudBurstForChange, hudVariantLabel } from './hudEffects.js';
+import {
+ appearancePreferencesToApi, mergeAppearancePreferences, normalizeAppearancePreferences,
+ parseAppearancePreferences, validThemeVariantId,
+} from './appearancePreferences.js';
+
+async function run(name, fn) { await fn(); console.log(`[pass] ${name}`); }
+const read = (relative) => readFileSync(new URL(relative, import.meta.url), 'utf8');
+const palette = (hex) => Object.fromEntries(THEME_COLOR_KEYS.map((key) => [key, hex]));
+const RED = { ...palette('#110000'), accent: '#FF4D57', 'send-bg': '#7DE7ED' };
+const CYAN = { ...palette('#001111'), accent: '#2BD3F5', 'send-bg': '#FF5A6E' };
+const YELLOW = { ...palette('#111100'), accent: '#F7E733', 'send-bg': '#2BD3F5' };
+function hudTheme(extra = {}) {
+ return {
+ schema_version: 1, id: 'ai-hud-test', name: 'HUD test', version: '1.0.0', mode: 'dark',
+ colors: RED,
+ appearance: { logo_color: '#FF3B48', home_title_color: '#F6ECEA', extend_to_titlebar: true },
+ chrome: { style: 'hud' },
+ variants: [
+ { id: 'red', name: '荒坂', colors: RED },
+ { id: 'cyan', name: '公共区', colors: CYAN, tint: '#22CCF2', appearance: { logo_color: '#2BD3F5' } },
+ { id: 'yellow', name: '限制区', colors: YELLOW, tint: '#F2E022' },
+ ],
+ ...extra,
+ };
+}
+function fakeRoot() {
+ const style = new Map();
+ const attrs = new Map();
+ return {
+ style: { setProperty: (k, v) => style.set(k, v), removeProperty: (k) => style.delete(k) },
+ setAttribute: (k, v) => attrs.set(k, String(v)),
+ removeAttribute: (k) => attrs.delete(k),
+ style$: style,
+ attrs,
+ };
+}
+
+// 场景:主题包声明 chrome。期望:只认识的 style(hud)生效;未知样式、错误类型一律忽略,
+// 让以后新增的外观类型在这个版本上退化成普通主题,而不是整个主题判坏。
+await run('chrome style is recognised only for known, well-formed values', () => {
+ assert.equal(themeChromeStyle(hudTheme()), 'hud');
+ for (const chrome of [undefined, null, 'hud', [], { style: 'glass' }, { style: 7 }, {}]) {
+ assert.equal(themeChromeStyle(hudTheme({ chrome })), null, JSON.stringify(chrome));
+ }
+});
+
+// 场景:variants 列表的校验是「全有或全无」。期望:合法列表原样返回;只要有一项坏掉
+// (数量越界、重名、ID 格式、28 色不全、多余键、tint 非 #RRGGBB、外观参数非法、名字为空)
+// 就整个忽略——部分保留会悄悄改变哪一项是默认风格。
+await run('variant lists are accepted whole or ignored whole', () => {
+ assert.equal(themeVariants(hudTheme()).length, 3);
+ const base = hudTheme().variants;
+ const broken = [
+ [base[0]],
+ [...base, ...base.map((v, i) => ({ ...v, id: `x${i}` })), { ...base[0], id: 'seventh' }],
+ [base[0], { ...base[1], id: 'red' }],
+ [base[0], { ...base[1], id: 'Cyan' }],
+ [base[0], { ...base[1], id: 'cy--an' }],
+ [base[0], { ...base[1], colors: { ...CYAN, accent: undefined } }],
+ [base[0], { ...base[1], colors: { ...CYAN, extra: '#000000' } }],
+ [base[0], { ...base[1], css: 'body{}' }],
+ [base[0], { ...base[1], tint: 'red' }],
+ [base[0], { ...base[1], appearance: { logo_color: 'url(x)' } }],
+ [base[0], { ...base[1], name: ' ' }],
+ 'red,cyan',
+ ];
+ for (const variants of broken) assert.deepEqual(themeVariants(hudTheme({ variants })), [], JSON.stringify(variants).slice(0, 80));
+ assert.equal(validThemeVariant(base[1]), true);
+});
+
+// 场景:按保存的风格 ID 选择。期望:空 ID、未知 ID(换了主题包或旧配置)都回到第一项;
+// 没有 variants 的主题返回 null,渲染沿用顶层配色。
+await run('variant selection falls back to the first entry', () => {
+ assert.equal(selectThemeVariant(hudTheme(), 'cyan').id, 'cyan');
+ assert.equal(selectThemeVariant(hudTheme(), '').id, 'red');
+ assert.equal(selectThemeVariant(hudTheme(), 'missing').id, 'red');
+ assert.equal(selectThemeVariant(hudTheme({ variants: undefined }), 'cyan'), null);
+ assert.deepEqual(themeVariantOptions(hudTheme()).map((o) => [o.id, o.name, o.swatches]), [
+ ['red', '荒坂', ['#110000', '#FF4D57', '#7DE7ED']],
+ ['cyan', '公共区', ['#001111', '#2BD3F5', '#FF5A6E']],
+ ['yellow', '限制区', ['#111100', '#F7E733', '#2BD3F5']],
+ ]);
+ assert.equal(hudVariantLabel(themeVariantOptions(hudTheme()), 'yellow'), '限制区');
+ assert.equal(hudVariantLabel(null, 'yellow'), '');
+});
+
+// 场景:选中风格后用于渲染的定义。期望:28 色整体替换为该风格;外观参数逐键覆盖,
+// 风格没写的键(标题色、通顶)保留顶层值;无 variants 的主题对象原样返回。
+await run('resolved variants replace colors and overlay appearance keys', () => {
+ const cyan = resolveThemeVariant(hudTheme(), 'cyan');
+ assert.equal(cyan.colors.accent, '#2BD3F5');
+ assert.deepEqual(cyan.appearance, { logo_color: '#2BD3F5', home_title_color: '#F6ECEA', extend_to_titlebar: true });
+ assert.equal(resolveThemeAppearance(cyan).logoColor, '#2BD3F5');
+ const plain = hudTheme({ variants: undefined });
+ assert.equal(resolveThemeVariant(plain, 'cyan'), plain);
+});
+
+// 场景:风格带 tint 时背景图按「color」混合染色(保留图片明暗,换成 tint 的色相)。
+// 期望:三张背景都插入同色渐变层并给出对应的 blend 列表;有透明度遮罩时遮罩仍在最上层;
+// 用户消息图层尺寸同步多出一个 100% 100%。没有 tint 时输出与旧版本逐字一致。
+await run('variant tint adds a color-blend layer without changing untinted output', () => {
+ const definition = {
+ ...hudTheme(),
+ session_background: { bytes: 1, sha256: 'a'.repeat(64) },
+ user_message_background: { bytes: 1, sha256: 'b'.repeat(64) },
+ appearance: { ...hudTheme().appearance, user_message_background_opacity: 0.8 },
+ };
+ const urls = { sessionBackgroundUrl: 'blob:s', userMessageBackgroundUrl: 'blob:u' };
+ const plain = themeCssProperties(definition, 'blob:h', urls);
+ assert.deepEqual(themeCssProperties(definition, 'blob:h', urls, { tint: null }), plain);
+ assert.equal(plain['--ace-home-background-blend'], undefined);
+ const tinted = themeCssProperties(definition, 'blob:h', urls, { tint: '#22CCF2' });
+ assert.equal(tinted['--ace-home-background-image'], 'linear-gradient(#22CCF2, #22CCF2), url("blob:h")');
+ assert.equal(tinted['--ace-home-background-blend'], 'color, normal');
+ assert.equal(tinted['--ace-session-background-blend'], 'color, normal');
+ assert.match(tinted['--ace-user-message-background-image'], /^linear-gradient\(rgba\([^)]*\), rgba\([^)]*\)\), linear-gradient\(#22CCF2, #22CCF2\), url\("blob:u"\)$/);
+ assert.equal(tinted['--ace-user-message-background-blend'], 'normal, color, normal');
+ assert.equal(tinted['--ace-user-message-background-size'], '100% 100%, 100% 100%, 720px auto');
+ // 非法 tint 不进入 CSS。
+ assert.deepEqual(themeCssProperties(definition, 'blob:h', urls, { tint: 'red); x: (' }), plain);
+});
+
+// 场景:应用 HUD 主题。期望:根节点写入 data-theme-chrome / data-theme-variant /
+// data-theme-effects,风格配色进入 CSS 变量;关闭动效时为 off;清理函数全部撤销。
+// 普通主题(无 chrome、无 variants)不出现这三个属性,保证其它主题完全不受影响。
+await run('applying a HUD theme writes chrome, variant and motion attributes', () => {
+ const root = fakeRoot();
+ const clear = applyInstalledTheme(root, hudTheme(), 'blob:h', {}, { variant: 'yellow', effects: false });
+ assert.equal(root.attrs.get('data-theme-chrome'), 'hud');
+ assert.equal(root.attrs.get('data-theme-variant'), 'yellow');
+ assert.equal(root.attrs.get('data-theme-effects'), 'off');
+ assert.equal(root.style$.get('--ace-accent'), '#F7E733');
+ assert.equal(root.style$.get('--ace-home-background-blend'), 'color, normal');
+ clear();
+ assert.equal(root.attrs.size, 0);
+ assert.equal(root.style$.size, 0);
+
+ const plainRoot = fakeRoot();
+ applyInstalledTheme(plainRoot, hudTheme({ chrome: undefined, variants: undefined }), 'blob:h', {}, { variant: 'cyan' });
+ for (const attr of ['data-theme-chrome', 'data-theme-variant', 'data-theme-effects']) assert.equal(plainRoot.attrs.has(attr), false);
+ assert.equal(plainRoot.style$.has('--ace-home-background-blend'), false);
+});
+
+// 场景:动效层的触发判断。期望:首次显示或换主题播放开屏扫描;同主题切换风格播放过场;
+// 开屏后 1.5 秒内的风格变化是 daemon 恢复已保存的风格,不能再叠一段过场。
+await run('motion bursts: boot on activation, switch on variant change after the grace period', () => {
+ const red = { colorTheme: 'ai-a', variant: 'red' };
+ assert.equal(hudBurstForChange(null, red), 'boot');
+ assert.equal(hudBurstForChange(red, { colorTheme: 'ai-b', variant: 'red' }), 'boot');
+ assert.equal(hudBurstForChange(red, { ...red, variant: 'cyan' }, HUD_SWITCH_GRACE_MS), 'switch');
+ assert.equal(hudBurstForChange(red, { ...red, variant: 'cyan' }, HUD_SWITCH_GRACE_MS - 1), null);
+ assert.equal(hudBurstForChange(red, { ...red }), null);
+ assert.equal(hudBurstForChange(red, null), null);
+});
+
+// 场景:外观偏好读写风格与动效。期望:旧 daemon 不返回这两个键时按默认(默认风格、动效开),
+// 不能让整份外观解析失败;只有显式 false 才关闭动效;非法风格 ID 归一为空串;
+// 写回接口时带上 theme_variant / theme_effects。
+await run('appearance preferences carry the theme variant and motion switch', () => {
+ const legacy = parseAppearancePreferences({ theme: 'dark', color_theme: 'blue', font_size: 'medium' });
+ assert.equal(legacy.themeVariant, '');
+ assert.equal(legacy.themeEffects, true);
+ const saved = parseAppearancePreferences({ theme: 'dark', color_theme: 'ai-x', font_size: 'small', theme_variant: 'cyan', theme_effects: false });
+ assert.equal(saved.themeVariant, 'cyan');
+ assert.equal(saved.themeEffects, false);
+ assert.equal(normalizeAppearancePreferences({ theme_variant: 'Cyan' }).themeVariant, '');
+ assert.equal(normalizeAppearancePreferences({ theme_effects: 'off' }).themeEffects, true);
+ const merged = mergeAppearancePreferences(saved, { themeVariant: 'yellow' });
+ assert.equal(merged.themeVariant, 'yellow');
+ assert.equal(merged.themeEffects, false);
+ const payload = appearancePreferencesToApi(merged);
+ assert.equal(payload.theme_variant, 'yellow');
+ assert.equal(payload.theme_effects, false);
+ for (const id of ['', 'red', 'cyan-2']) assert.equal(validThemeVariantId(id), true);
+ for (const id of ['Red', '-red', 'a--b', 'x'.repeat(25), 7, null]) assert.equal(validThemeVariantId(id), false);
+});
+
+// 场景:themeHud.css 的作用范围。期望:每条规则要么限定在 :root[data-theme-chrome="hud"] 下,
+// 要么是只在 HUD 主题里挂载的动效层类名(.ace-hud-*);动画规则还必须要求动效开关为 on。
+// 回归意义:这份样式随应用常驻加载,一条没加作用域的选择器就会改到所有主题。
+await run('themeHud.css only styles HUD themes', () => {
+ const css = read('../styles/themeHud.css').replace(/\/\*[\s\S]*?\*\//g, '');
+ const blocks = [];
+ let depth = 0; let start = 0; let atRule = [];
+ for (let i = 0; i < css.length; i += 1) {
+ if (css[i] === '{') {
+ const prelude = css.slice(start, i).trim();
+ if (prelude.startsWith('@')) atRule.push(depth); else if (!atRule.length) blocks.push(prelude);
+ depth += 1; start = i + 1;
+ } else if (css[i] === '}') {
+ depth -= 1; start = i + 1;
+ if (atRule.length && atRule[atRule.length - 1] === depth) atRule.pop();
+ } else if (css[i] === ';' && !atRule.length && depth === 0) start = i + 1;
+ }
+ assert.ok(blocks.length > 40, `parsed ${blocks.length} rules`);
+ const selectors = blocks.flatMap((prelude) => prelude.split(/,(?![^(]*\))/).map((s) => s.trim()).filter(Boolean));
+ for (const selector of selectors) {
+ const scoped = selector.startsWith(':root[data-theme-chrome="hud"]') || /^\.ace-hud-[a-z-]+/.test(selector);
+ assert.ok(scoped, `unscoped selector: ${selector}`);
+ }
+ for (const prelude of blocks) {
+ const body = css.slice(css.indexOf(prelude) + prelude.length, css.indexOf('}', css.indexOf(prelude)));
+ if (/animation(-name)?\s*:/.test(body) && !prelude.includes('.ace-hud-')) {
+ assert.match(prelude, /\[data-theme-effects="on"\]/, `animation without effects gate: ${prelude.slice(0, 80)}`);
+ }
+ }
+});
+
+// 场景:设置页的风格选择与动效开关。期望:只在当前主题有风格 / 声明 HUD 时渲染,
+// 按钮带 data-theme-variant-option 便于定位;动效层由 ThemeProvider 挂载。
+await run('settings and provider wire the variant picker and motion layer', () => {
+ const settings = read('../components/SettingsPage.jsx');
+ assert.match(settings, /themeVariantOptions\.length > 0 &&/);
+ assert.match(settings, /data-theme-variant-option=\{option\.id\}/);
+ assert.match(settings, /\{themeChrome && \(/);
+ const provider = read('../theme.jsx');
+ assert.match(provider, /
VARIANT_KEYS.has(key))) return false;
+ const name = typeof value.name === 'string' ? value.name.trim() : '';
+ return typeof value.id === 'string' && value.id.length <= 24 && /^[a-z0-9]+(?:-[a-z0-9]+)*$/.test(value.id)
+ && !!name && [...name].length <= 24 && !/[\u0000-\u001f\u007f]/.test(value.name)
+ && !!value.colors && typeof value.colors === 'object' && !Array.isArray(value.colors)
+ && Object.keys(value.colors).length === THEME_COLOR_KEYS.length
+ && THEME_COLOR_KEYS.every((key) => validThemeHexColor(value.colors[key]))
+ && (!Object.hasOwn(value, 'appearance') || validThemeAppearance(value.appearance))
+ && (!Object.hasOwn(value, 'tint') || validThemeHexColor(value.tint));
+}
+
+// All-or-nothing: a partly broken list would change which choice is default.
+export function themeVariants(definition) {
+ const list = definition?.variants;
+ if (!Array.isArray(list) || list.length < 2 || list.length > MAX_THEME_VARIANTS) return [];
+ const ids = new Set();
+ for (const variant of list) {
+ if (!validThemeVariant(variant) || ids.has(variant.id)) return [];
+ ids.add(variant.id);
+ }
+ return list;
+}
+
+// The first variant is the default; a stale or foreign ID falls back to it.
+export function selectThemeVariant(definition, variantId) {
+ const variants = themeVariants(definition);
+ if (!variants.length) return null;
+ return variants.find((variant) => variant.id === variantId) || variants[0];
+}
+
+// Returns the definition that rendering uses: the selected variant replaces
+// the palette and overlays its appearance keys; everything else is unchanged.
+export function resolveThemeVariant(definition, variantId) {
+ const variant = selectThemeVariant(definition, variantId);
+ if (!variant) return definition;
+ const appearance = { ...(definition.appearance || {}), ...(variant.appearance || {}) };
+ return {
+ ...definition,
+ colors: variant.colors,
+ ...(Object.keys(appearance).length ? { appearance } : {}),
+ };
+}
+
+export function themeVariantOptions(definition) {
+ return themeVariants(definition).map((variant) => ({
+ id: variant.id,
+ name: variant.name.trim(),
+ swatches: [variant.colors.bg, variant.colors.accent, variant.colors['send-bg']],
+ }));
+}
+
export function resolveThemeAppearance(definition) {
if (!validThemeDefinition(definition)) throw new Error('主题配色数据无效');
const appearance = definition.appearance || {};
@@ -82,8 +148,11 @@ export async function loadThemeResources(definition, readImage, createUrl = (blo
return item;
}
-export function themeCssProperties(definition, backgroundUrl, extraUrls = {}) {
+export function themeCssProperties(definition, backgroundUrl, extraUrls = {}, { tint = null } = {}) {
if (!validThemeDefinition(definition)) throw new Error('主题配色数据无效');
+ // A variant tint keeps each artwork's luminance and takes the tint's hue
+ // (CSS "color" blending), so one set of images serves every variant.
+ const tintLayer = validThemeHexColor(tint) ? `linear-gradient(${tint}, ${tint}), ` : '';
const result = {};
for (const key of THEME_COLOR_KEYS) {
const hex = definition.colors[key];
@@ -109,19 +178,24 @@ export function themeCssProperties(definition, backgroundUrl, extraUrls = {}) {
if (typeof url !== 'string' || !url.startsWith('blob:') || /["()\s]/.test(url)) continue;
const opacity = overrides[`${key}_opacity`];
const veil = opacity === undefined ? '' : `linear-gradient(rgba(${rgb(color)}, ${1 - opacity}), rgba(${rgb(color)}, ${1 - opacity})), `;
- result[`--ace-${resource.region}-background-image`] = `${veil}url("${url}")`;
+ result[`--ace-${resource.region}-background-image`] = `${veil}${tintLayer}url("${url}")`;
+ if (tintLayer) result[`--ace-${resource.region}-background-blend`] = veil ? 'normal, color, normal' : 'color, normal';
if (resource.region === 'user-message') {
// A shared artwork width keeps decorations identical in every message.
- // Only the color veil stretches with the bubble; artwork is cropped.
- result['--ace-user-message-background-size'] = veil ? '100% 100%, 720px auto' : '720px auto';
+ // Only the color veil (and tint) stretch with the bubble; artwork is cropped.
+ const fill = (veil ? '100% 100%, ' : '') + (tintLayer ? '100% 100%, ' : '');
+ result['--ace-user-message-background-size'] = `${fill}720px auto`;
}
}
return result;
}
-export function applyInstalledTheme(root, definition, backgroundUrl, extraUrls = {}) {
- const properties = definition ? themeCssProperties(definition, backgroundUrl, extraUrls) : {};
- const appearance = definition ? resolveThemeAppearance(definition) : null;
+export function applyInstalledTheme(root, definition, backgroundUrl, extraUrls = {}, { variant = '', effects = true } = {}) {
+ const selected = definition ? selectThemeVariant(definition, variant) : null;
+ const effective = definition ? resolveThemeVariant(definition, variant) : null;
+ const chrome = definition ? themeChromeStyle(definition) : null;
+ const properties = effective ? themeCssProperties(effective, backgroundUrl, extraUrls, { tint: selected?.tint }) : {};
+ const appearance = effective ? resolveThemeAppearance(effective) : null;
const hasBackground = !!properties['--ace-home-background-image'];
const attributes = {
'data-installed-theme': definition?.id,
@@ -131,6 +205,9 @@ export function applyInstalledTheme(root, definition, backgroundUrl, extraUrls =
'data-theme-extend-to-titlebar': hasBackground ? String(appearance.extendToTitlebar) : null,
'data-theme-titlebar-controls': hasBackground && appearance.whiteTitlebarControls ? 'white' : null,
'data-theme-logo-color': appearance?.logoColor ? 'custom' : null,
+ 'data-theme-chrome': chrome,
+ 'data-theme-variant': selected?.id || null,
+ 'data-theme-effects': chrome ? (effects === false ? 'off' : 'on') : null,
};
for (const [key, value] of Object.entries(properties)) root.style.setProperty(key, value);
for (const [key, value] of Object.entries(attributes)) {
diff --git a/web/src/styles/globals.css b/web/src/styles/globals.css
index c14e8510..cec11727 100644
--- a/web/src/styles/globals.css
+++ b/web/src/styles/globals.css
@@ -3050,6 +3050,7 @@ body {
inset: 0 0 0 var(--ace-home-sidebar-width, 0px);
background-color: var(--ace-home-background-color, var(--ace-bg));
background-image: var(--ace-home-background-image);
+ background-blend-mode: var(--ace-home-background-blend, normal);
background-position: right top;
background-size: cover;
background-repeat: no-repeat;
@@ -3084,6 +3085,7 @@ body {
inset: 0;
z-index: -1;
background-image: var(--ace-session-background-image);
+ background-blend-mode: var(--ace-session-background-blend, normal);
background-position: center;
background-size: cover;
background-repeat: no-repeat;
@@ -3092,6 +3094,7 @@ body {
[data-theme-user-message-background="true"] .ace-user-message-bubble {
background-color: var(--ace-user-message-background-color, var(--ace-accent-bg));
background-image: var(--ace-user-message-background-image);
+ background-blend-mode: var(--ace-user-message-background-blend, normal);
background-position: right bottom;
background-size: var(--ace-user-message-background-size, 720px auto);
background-repeat: no-repeat;
diff --git a/web/src/styles/themeHud.css b/web/src/styles/themeHud.css
new file mode 100644
index 00000000..e3e57ddc
--- /dev/null
+++ b/web/src/styles/themeHud.css
@@ -0,0 +1,766 @@
+/* HUD chrome for theme packages that declare `"chrome": {"style": "hud"}`.
+ *
+ * Every rule is scoped to :root[data-theme-chrome="hud"], so other themes are
+ * untouched. Packages still carry only data: shapes, lines and motion live
+ * here and take their colors from the palette variables (--ace-accent for
+ * lines, --ace-send-bg as the secondary/focus color).
+ *
+ * Panels get cut corners from gradient-drawn frames on ::after. Containers
+ * that may host popovers (the composer) keep their own box unclipped and
+ * paint the clipped fill on ::before instead.
+ */
+
+:root[data-theme-chrome="hud"] {
+ --hud-cut: 10px;
+ --hud-line: rgba(var(--ace-accent-rgb), .62);
+ --hud-hot: var(--ace-accent);
+ --hud-alt: var(--ace-send-bg);
+ --hud-alt-rgb: var(--ace-send-bg-rgb);
+}
+
+/* ── Shared cut-corner frame (top-left and bottom-right corners) ────────── */
+:root[data-theme-chrome="hud"] :is(
+ .ace-user-message-bubble,
+ .ace-copyable-code,
+ .ace-turn-files,
+ .ace-tool-activity,
+ .ace-home-hint-card,
+ .ace-home-project-row,
+ .ace-sidebar-session-row
+)::after {
+ content: "";
+ position: absolute;
+ inset: 0;
+ z-index: 1;
+ pointer-events: none;
+ --f: var(--hud-frame, var(--hud-line));
+ --h: var(--hud-frame-hot, var(--hud-hot));
+ --c: var(--hud-cut);
+ background:
+ linear-gradient(var(--h), var(--h)) left var(--c) top 0 / var(--hud-hot-len, 34px) 2px no-repeat,
+ linear-gradient(var(--h), var(--h)) right var(--c) bottom 0 / var(--hud-hot-len, 34px) 2px no-repeat,
+ linear-gradient(var(--f), var(--f)) right 0 top 0 / calc(100% - var(--c)) 1px no-repeat,
+ linear-gradient(var(--f), var(--f)) left 0 bottom 0 / calc(100% - var(--c)) 1px no-repeat,
+ linear-gradient(var(--f), var(--f)) left 0 bottom 0 / 1px calc(100% - var(--c)) no-repeat,
+ linear-gradient(var(--f), var(--f)) right 0 top 0 / 1px calc(100% - var(--c)) no-repeat,
+ linear-gradient(to bottom right, transparent calc(50% - .6px), var(--f) 0 calc(50% + .6px), transparent 0) left top / var(--c) var(--c) no-repeat,
+ linear-gradient(to bottom right, transparent calc(50% - .6px), var(--f) 0 calc(50% + .6px), transparent 0) right bottom / var(--c) var(--c) no-repeat;
+}
+
+/* ── Window frame ─────────────────────────────────────────────────────── */
+:root[data-theme-chrome="hud"] .ace-app-shell::after {
+ content: "";
+ position: absolute;
+ inset: 0;
+ z-index: 70;
+ pointer-events: none;
+ --c: 16px;
+ --f: rgba(var(--ace-accent-rgb), .5);
+ --h: var(--ace-accent);
+ background:
+ linear-gradient(var(--h), var(--h)) left var(--c) top 0 / 88px 2px no-repeat,
+ linear-gradient(var(--h), var(--h)) right var(--c) bottom 0 / 88px 2px no-repeat,
+ linear-gradient(var(--h), var(--h)) right 0 top var(--c) / 2px 56px no-repeat,
+ linear-gradient(var(--h), var(--h)) left 0 bottom var(--c) / 2px 56px no-repeat,
+ linear-gradient(var(--f), var(--f)) left var(--c) top 0 / calc(100% - 2 * var(--c)) 1px no-repeat,
+ linear-gradient(var(--f), var(--f)) left var(--c) bottom 0 / calc(100% - 2 * var(--c)) 1px no-repeat,
+ linear-gradient(var(--f), var(--f)) left 0 top var(--c) / 1px calc(100% - 2 * var(--c)) no-repeat,
+ linear-gradient(var(--f), var(--f)) right 0 top var(--c) / 1px calc(100% - 2 * var(--c)) no-repeat,
+ linear-gradient(to bottom right, transparent calc(50% - .7px), var(--f) 0 calc(50% + .7px), transparent 0) left top / var(--c) var(--c) no-repeat,
+ linear-gradient(to bottom left, transparent calc(50% - .7px), var(--f) 0 calc(50% + .7px), transparent 0) right top / var(--c) var(--c) no-repeat,
+ linear-gradient(to bottom left, transparent calc(50% - .7px), var(--f) 0 calc(50% + .7px), transparent 0) left bottom / var(--c) var(--c) no-repeat,
+ linear-gradient(to bottom right, transparent calc(50% - .7px), var(--f) 0 calc(50% + .7px), transparent 0) right bottom / var(--c) var(--c) no-repeat;
+}
+
+/* ── Title bar and sidebar ────────────────────────────────────────────── */
+:root[data-theme-chrome="hud"] .ace-topbar {
+ box-shadow: inset 0 -1px 0 rgba(var(--ace-accent-rgb), .38);
+}
+:root[data-theme-chrome="hud"] .ace-topbar::before {
+ border-right-color: rgba(var(--ace-accent-rgb), .6);
+}
+:root[data-theme-chrome="hud"] .ace-sidebar {
+ background:
+ repeating-linear-gradient(to bottom, rgba(var(--ace-accent-rgb), .32) 0 2px, transparent 2px 9px) right 7px top 14px / 2px calc(100% - 28px) no-repeat,
+ linear-gradient(180deg, rgba(var(--ace-accent-rgb), .07), transparent 26%, transparent 74%, rgba(var(--ace-accent-rgb), .05)),
+ var(--ace-shell-bg);
+ box-shadow:
+ inset -1px 0 0 rgba(var(--ace-accent-rgb), .6),
+ inset -18px 0 26px -24px rgba(var(--ace-accent-rgb), .9);
+}
+:root[data-theme-chrome="hud"] .ace-sidebar :is(.ace-sidebar-fixed-nav, .ace-sidebar-footer) .ace-icon {
+ color: var(--ace-accent);
+}
+:root[data-theme-chrome="hud"] .ace-brand-logo {
+ filter: drop-shadow(0 0 5px rgba(var(--ace-logo-color-rgb, var(--ace-accent-rgb)), .55));
+}
+:root[data-theme-chrome="hud"] .ace-sidebar-section-text {
+ letter-spacing: .06em;
+}
+:root[data-theme-chrome="hud"] .ace-sidebar-section-header {
+ position: relative;
+}
+/* Small bracket before each section heading. */
+:root[data-theme-chrome="hud"] .ace-sidebar-section-header::before {
+ content: "";
+ position: absolute;
+ left: 4px;
+ top: 50%;
+ width: 6px;
+ height: 12px;
+ margin-top: -6px;
+ border: 1px solid rgba(var(--ace-accent-rgb), .7);
+ border-right: 0;
+ pointer-events: none;
+}
+:root[data-theme-chrome="hud"] .ace-sidebar-session-row {
+ position: relative;
+ border-radius: 0;
+ --hud-cut: 7px;
+ --hud-hot-len: 16px;
+}
+:root[data-theme-chrome="hud"] .ace-sidebar-session-row::after {
+ opacity: 0;
+ transition: opacity .14s ease;
+}
+:root[data-theme-chrome="hud"] .ace-sidebar-session-row:hover::after {
+ opacity: .55;
+}
+:root[data-theme-chrome="hud"] .ace-sidebar-session-row[data-desktop-session-pinned="true"]::after {
+ opacity: .8;
+}
+:root[data-theme-chrome="hud"] .ace-sidebar-session-row.is-tree-active {
+ color: var(--hud-alt);
+ background: linear-gradient(90deg, rgba(var(--hud-alt-rgb), .17), rgba(var(--hud-alt-rgb), .04) 70%, transparent);
+}
+:root[data-theme-chrome="hud"] .ace-sidebar-session-row.is-tree-active::after {
+ opacity: 1;
+ --hud-frame: rgba(var(--hud-alt-rgb), .85);
+ --hud-frame-hot: var(--hud-alt);
+ filter: drop-shadow(0 0 3px rgba(var(--hud-alt-rgb), .75));
+}
+
+/* ── Conversation panel: left ruler and right tick marks ──────────────── */
+:root[data-theme-chrome="hud"] .ace-session-panel::after {
+ content: "";
+ position: absolute;
+ top: 18px;
+ bottom: 18px;
+ left: 10px;
+ width: 9px;
+ z-index: 0;
+ pointer-events: none;
+ background:
+ linear-gradient(rgba(var(--ace-accent-rgb), .55), rgba(var(--ace-accent-rgb), .55)) left 0 top 0 / 9px 2px no-repeat,
+ linear-gradient(rgba(var(--ace-accent-rgb), .55), rgba(var(--ace-accent-rgb), .55)) left 0 bottom 0 / 9px 2px no-repeat,
+ repeating-linear-gradient(to bottom, rgba(var(--ace-accent-rgb), .45) 0 1px, transparent 1px 48px) left 0 top 0 / 9px 100% no-repeat,
+ repeating-linear-gradient(to bottom, rgba(var(--ace-accent-rgb), .3) 0 1px, transparent 1px 8px) left 0 top 0 / 5px 100% no-repeat;
+}
+
+/* ── User message: cut-corner plate with a user glyph ─────────────────── */
+:root[data-theme-chrome="hud"] .ace-user-message-bubble {
+ position: relative;
+ border-radius: 0;
+ border-color: transparent;
+ padding-left: 36px;
+ clip-path: polygon(var(--hud-cut) 0, 100% 0, 100% calc(100% - var(--hud-cut)), calc(100% - var(--hud-cut)) 100%, 0 100%, 0 var(--hud-cut));
+ --hud-frame: rgba(var(--ace-accent-rgb), .8);
+}
+:root[data-theme-chrome="hud"] .ace-user-message-bubble::before {
+ content: "";
+ position: absolute;
+ left: 13px;
+ top: 11px;
+ width: 14px;
+ height: 14px;
+ pointer-events: none;
+ background: var(--ace-accent);
+ -webkit-mask: url("data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 16 16'%3E%3Ccircle cx='8' cy='5' r='3.2' fill='none' stroke='black' stroke-width='1.6'/%3E%3Cpath d='M2.5 15c.6-3.1 2.7-4.8 5.5-4.8s4.9 1.7 5.5 4.8' fill='none' stroke='black' stroke-width='1.6'/%3E%3C/svg%3E") center / contain no-repeat;
+ mask: url("data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 16 16'%3E%3Ccircle cx='8' cy='5' r='3.2' fill='none' stroke='black' stroke-width='1.6'/%3E%3Cpath d='M2.5 15c.6-3.1 2.7-4.8 5.5-4.8s4.9 1.7 5.5 4.8' fill='none' stroke='black' stroke-width='1.6'/%3E%3C/svg%3E") center / contain no-repeat;
+}
+
+/* ── Code blocks: header strip that shows the fence language ──────────── */
+:root[data-theme-chrome="hud"] .ace-copyable-code {
+ --hud-cut: 9px;
+ --hud-hot-len: 56px;
+ padding-top: 30px;
+ background: var(--ace-code-bg);
+ clip-path: polygon(var(--hud-cut) 0, 100% 0, 100% calc(100% - var(--hud-cut)), calc(100% - var(--hud-cut)) 100%, 0 100%, 0 var(--hud-cut));
+}
+:root[data-theme-chrome="hud"] .ace-copyable-code::before {
+ content: attr(data-code-lang);
+ position: absolute;
+ inset: 0 0 auto 0;
+ height: 30px;
+ display: flex;
+ align-items: center;
+ padding-left: 18px;
+ font: 600 11px/1 ui-monospace, "Cascadia Code", Consolas, monospace;
+ letter-spacing: .14em;
+ text-transform: uppercase;
+ color: var(--ace-fg-2);
+ pointer-events: none;
+ background:
+ repeating-linear-gradient(135deg, rgba(var(--ace-accent-rgb), .16) 0 2px, transparent 2px 6px) right 52px center / 64px 10px no-repeat,
+ linear-gradient(rgba(var(--ace-accent-rgb), .12), rgba(var(--ace-accent-rgb), .04));
+ border-bottom: 1px solid rgba(var(--ace-accent-rgb), .32);
+}
+:root[data-theme-chrome="hud"] .ace-copyable-code > pre,
+:root[data-theme-chrome="hud"] .ace-copyable-code .hljs {
+ background: transparent !important;
+ border-radius: 0 !important;
+ border: 0 !important;
+}
+:root[data-theme-chrome="hud"] .ace-copyable-code .ace-code-copy-btn,
+:root[data-theme-chrome="hud"] .ace-copyable-code .ace-code-actions {
+ top: 3px;
+ z-index: 2;
+}
+/* Syntax colors follow the variant: keys in the accent, values in the
+ secondary color. Both stay above 4.5:1 on every variant's code-bg. */
+:root[data-theme-chrome="hud"] .hljs { color: var(--ace-code-fg); }
+:root[data-theme-chrome="hud"] :is(.hljs-keyword, .hljs-selector-tag, .hljs-name, .hljs-type, .hljs-attr, .hljs-attribute, .hljs-selector-class, .hljs-selector-id, .hljs-template-tag, .hljs-variable.language_) { color: var(--ace-accent); }
+:root[data-theme-chrome="hud"] :is(.hljs-string, .hljs-number, .hljs-literal, .hljs-regexp, .hljs-symbol, .hljs-built_in, .hljs-meta, .hljs-selector-attr, .hljs-selector-pseudo) { color: var(--ace-send-bg); }
+:root[data-theme-chrome="hud"] :is(.hljs-title, .hljs-title.function_, .hljs-title.class_, .hljs-section) { color: var(--ace-fg); }
+:root[data-theme-chrome="hud"] :is(.hljs-comment, .hljs-quote) { color: var(--ace-fg-mute); font-style: italic; }
+
+/* ── Tool rows and changed-file summary ───────────────────────────────── */
+:root[data-theme-chrome="hud"] .ace-turn-files {
+ position: relative;
+ padding: 8px 10px 6px;
+ background: rgba(var(--ace-surface-rgb), .72);
+ clip-path: polygon(var(--hud-cut) 0, 100% 0, 100% calc(100% - var(--hud-cut)), calc(100% - var(--hud-cut)) 100%, 0 100%, 0 var(--hud-cut));
+ --hud-frame: rgba(var(--ace-accent-rgb), .45);
+}
+:root[data-theme-chrome="hud"] .ace-turn-file-row {
+ border-radius: 0;
+ border-color: rgba(var(--ace-accent-rgb), .22);
+ background: transparent;
+}
+/* Each tool call is its own boxed row (auto-collapse off). The fill sits on
+ ::before so expanded details are never clipped. */
+:root[data-theme-chrome="hud"] .ace-tool-activity {
+ position: relative;
+ isolation: isolate;
+ /* Same right edge as the assistant text (.ace-chat-row-assistant-gutter). */
+ max-width: 88%;
+ margin: 3px 0;
+ padding: 1px 8px;
+ --hud-cut: 7px;
+ --hud-hot-len: 18px;
+ --hud-frame: rgba(var(--ace-accent-rgb), .3);
+ --hud-frame-hot: rgba(var(--ace-accent-rgb), .8);
+}
+:root[data-theme-chrome="hud"] .ace-tool-activity::before {
+ content: "";
+ position: absolute;
+ inset: 0;
+ z-index: -1;
+ pointer-events: none;
+ background: rgba(var(--ace-surface-rgb), .55);
+ clip-path: polygon(var(--hud-cut) 0, 100% 0, 100% calc(100% - var(--hud-cut)), calc(100% - var(--hud-cut)) 100%, 0 100%, 0 var(--hud-cut));
+}
+:root[data-theme-chrome="hud"] .ace-tool-activity:hover {
+ --hud-frame: rgba(var(--ace-accent-rgb), .6);
+}
+:root[data-theme-chrome="hud"] .ace-activity-line-processed {
+ box-shadow: inset 0 -1px 0 rgba(var(--ace-accent-rgb), .22);
+}
+:root[data-theme-chrome="hud"] .ace-activity-line-label {
+ letter-spacing: .04em;
+}
+
+/* ── Composer: notched top edge, chamfered bottom corners ─────────────── */
+:root[data-theme-chrome="hud"] .ace-composer-card {
+ --n: 6px;
+ --c: 12px;
+ --w: min(150px, 22%);
+ isolation: isolate;
+ background: transparent !important;
+ border-color: transparent !important;
+ border-radius: 0 !important;
+ box-shadow: none !important;
+}
+:root[data-theme-chrome="hud"] .ace-composer-card::before {
+ content: "";
+ position: absolute;
+ inset: 0;
+ z-index: -1;
+ pointer-events: none;
+ background: rgba(var(--ace-surface-rgb), var(--hud-composer-opacity, .9));
+ clip-path: polygon(
+ 0 var(--n), calc(50% - var(--w)) var(--n), calc(50% - var(--w) + var(--n)) 0,
+ calc(50% + var(--w) - var(--n)) 0, calc(50% + var(--w)) var(--n), 100% var(--n),
+ 100% calc(100% - var(--c)), calc(100% - var(--c)) 100%, var(--c) 100%, 0 calc(100% - var(--c)));
+}
+:root[data-theme-chrome="hud"] .ace-inputbar-hero-card::before {
+ --hud-composer-opacity: var(--ace-home-composer-opacity, .9);
+}
+:root[data-theme-chrome="hud"] .ace-composer-card::after {
+ content: "";
+ position: absolute;
+ inset: 0;
+ z-index: 1;
+ pointer-events: none;
+ --l: rgba(var(--ace-accent-rgb), .66);
+ --h: var(--ace-accent);
+ background:
+ linear-gradient(var(--h), var(--h)) left 0 top var(--n) / 52px 2px no-repeat,
+ linear-gradient(var(--h), var(--h)) right 0 top var(--n) / 52px 2px no-repeat,
+ linear-gradient(var(--h), var(--h)) center top / 96px 2px no-repeat,
+ linear-gradient(var(--h), var(--h)) left var(--c) bottom 0 / 34px 2px no-repeat,
+ linear-gradient(var(--h), var(--h)) right var(--c) bottom 0 / 34px 2px no-repeat,
+ linear-gradient(var(--l), var(--l)) left 0 top var(--n) / calc(50% - var(--w)) 1px no-repeat,
+ linear-gradient(var(--l), var(--l)) right 0 top var(--n) / calc(50% - var(--w)) 1px no-repeat,
+ linear-gradient(var(--l), var(--l)) center top / calc(2 * var(--w) - 2 * var(--n)) 1px no-repeat,
+ linear-gradient(to bottom right, transparent calc(50% - .6px), var(--l) 0 calc(50% + .6px), transparent 0) calc(50% - var(--w) + var(--n) / 2) 0 / var(--n) var(--n) no-repeat,
+ linear-gradient(to bottom left, transparent calc(50% - .6px), var(--l) 0 calc(50% + .6px), transparent 0) calc(50% + var(--w) - var(--n) / 2) 0 / var(--n) var(--n) no-repeat,
+ linear-gradient(var(--l), var(--l)) left 0 top var(--n) / 1px calc(100% - var(--n) - var(--c)) no-repeat,
+ linear-gradient(var(--l), var(--l)) right 0 top var(--n) / 1px calc(100% - var(--n) - var(--c)) no-repeat,
+ linear-gradient(var(--l), var(--l)) left var(--c) bottom 0 / calc(100% - 2 * var(--c)) 1px no-repeat,
+ linear-gradient(to bottom left, transparent calc(50% - .6px), var(--l) 0 calc(50% + .6px), transparent 0) left bottom / var(--c) var(--c) no-repeat,
+ linear-gradient(to bottom right, transparent calc(50% - .6px), var(--l) 0 calc(50% + .6px), transparent 0) right bottom / var(--c) var(--c) no-repeat;
+}
+:root[data-theme-chrome="hud"] .ace-composer-card:focus-within::after {
+ --l: rgba(var(--ace-accent-rgb), .95);
+ filter: drop-shadow(0 0 4px rgba(var(--ace-accent-rgb), .55));
+}
+/* The send button keeps the secondary color even while empty (dimmed), as in
+ the reference; the stop action switches to the accent so it reads as busy. */
+:root[data-theme-chrome="hud"] .ace-composer-send {
+ background-color: var(--ace-send-bg);
+ color: var(--ace-send-fg);
+}
+:root[data-theme-chrome="hud"] .ace-composer-send:disabled {
+ opacity: .45;
+}
+:root[data-theme-chrome="hud"] .ace-composer-send:not(:disabled) {
+ box-shadow: 0 0 0 1px rgba(var(--hud-alt-rgb), .55), 0 0 12px rgba(var(--hud-alt-rgb), .65);
+}
+:root[data-theme-chrome="hud"] .ace-composer-send[data-composer-action="stop"] {
+ background-color: var(--ace-accent);
+ color: var(--ace-bg);
+ box-shadow: 0 0 0 1px rgba(var(--ace-accent-rgb), .6), 0 0 12px rgba(var(--ace-accent-rgb), .7);
+}
+:root[data-theme-chrome="hud"] .ace-composer-permission-button {
+ border-radius: 0;
+}
+
+/* ── Home ─────────────────────────────────────────────────────────────── */
+:root[data-theme-chrome="hud"] .ace-home-title {
+ letter-spacing: .04em;
+ text-shadow: 0 0 14px rgba(var(--ace-accent-rgb), .55), 0 0 2px rgba(var(--ace-accent-rgb), .6);
+}
+:root[data-theme-chrome="hud"] .ace-home-content > .ace-home-logo::before,
+:root[data-theme-chrome="hud"] .ace-home-content > .ace-home-logo::after {
+ content: "";
+ position: absolute;
+ left: 50%;
+ top: 50%;
+ border-radius: 50%;
+ pointer-events: none;
+}
+/* The dark-mode logo glow also lives on ::before (translate, mask, gradient);
+ reset all of it so the ring is centred and the glow does not double up. */
+:root[data-theme-chrome="hud"] .ace-home-content > .ace-home-logo::before {
+ width: 78px;
+ height: 78px;
+ margin: -39px 0 0 -39px;
+ transform: none;
+ background: none;
+ -webkit-mask: none;
+ mask: none;
+ border: 1px dashed rgba(var(--ace-accent-rgb), .55);
+}
+:root[data-theme-chrome="hud"] .ace-home-content > .ace-home-logo::after {
+ width: 96px;
+ height: 96px;
+ margin: -48px 0 0 -48px;
+ background: conic-gradient(var(--ace-accent) 0 38deg, transparent 0 120deg, var(--ace-accent) 0 142deg, transparent 0 240deg, var(--ace-accent) 0 252deg, transparent 0);
+ -webkit-mask: radial-gradient(closest-side, transparent calc(100% - 2.5px), #000 calc(100% - 2px) calc(100% - .5px), transparent 100%);
+ mask: radial-gradient(closest-side, transparent calc(100% - 2.5px), #000 calc(100% - 2px) calc(100% - .5px), transparent 100%);
+ opacity: .85;
+}
+:root[data-theme-chrome="hud"] .ace-home-hint-card,
+:root[data-theme-chrome="hud"] .ace-home-project-row {
+ position: relative;
+ border-radius: 0;
+ border-color: transparent;
+ clip-path: polygon(var(--hud-cut) 0, 100% 0, 100% calc(100% - var(--hud-cut)), calc(100% - var(--hud-cut)) 100%, 0 100%, 0 var(--hud-cut));
+ --hud-frame: rgba(var(--ace-accent-rgb), .42);
+}
+:root[data-theme-chrome="hud"] .ace-home-hint-card .ace-icon {
+ color: var(--ace-accent);
+}
+:root[data-theme-chrome="hud"] .ace-home-hint-card:hover {
+ background-color: rgba(var(--ace-accent-rgb), .1);
+ --hud-frame: rgba(var(--ace-accent-rgb), .85);
+}
+:root[data-theme-chrome="hud"] .ace-home-project-row {
+ --hud-cut: 6px;
+ --hud-hot-len: 14px;
+}
+
+/* ── Diffs (diff2html, every .ace-diff: tool rows, change list, review) ──
+ diff2html reads its colors from --d2h-* variables; derive them all from the
+ palette so the light defaults (yellow "changed" pairs, pink/green word
+ highlights, grey headers) never show inside a HUD theme. */
+:root[data-theme-chrome="hud"] {
+ --d2h-bg-color: var(--ace-code-bg);
+ --d2h-border-color: rgba(var(--ace-accent-rgb), .38);
+ --d2h-dim-color: var(--ace-fg-mute);
+ --d2h-line-border-color: rgba(var(--ace-accent-rgb), .14);
+ --d2h-file-header-bg-color: rgba(var(--ace-accent-rgb), .09);
+ --d2h-file-header-border-color: rgba(var(--ace-accent-rgb), .32);
+ --d2h-empty-placeholder-bg-color: rgba(var(--ace-fg-mute-rgb), .06);
+ --d2h-empty-placeholder-border-color: rgba(var(--ace-accent-rgb), .14);
+ --d2h-selected-color: rgba(var(--ace-send-bg-rgb), .18);
+ --d2h-ins-bg-color: rgba(var(--ace-ok-rgb), .1);
+ --d2h-ins-border-color: rgba(var(--ace-ok-rgb), .35);
+ --d2h-ins-highlight-bg-color: rgba(var(--ace-ok-rgb), .32);
+ --d2h-ins-label-color: var(--ace-ok);
+ --d2h-del-bg-color: rgba(var(--ace-danger-rgb), .1);
+ --d2h-del-border-color: rgba(var(--ace-danger-rgb), .35);
+ --d2h-del-highlight-bg-color: rgba(var(--ace-danger-rgb), .34);
+ --d2h-del-label-color: var(--ace-danger);
+ --d2h-change-del-color: rgba(var(--ace-danger-rgb), .1);
+ --d2h-change-ins-color: rgba(var(--ace-ok-rgb), .1);
+ --d2h-info-bg-color: rgba(var(--ace-accent-rgb), .07);
+ --d2h-info-border-color: rgba(var(--ace-accent-rgb), .22);
+ --d2h-change-label-color: var(--ace-accent);
+ --d2h-moved-label-color: var(--ace-send-bg);
+}
+:root[data-theme-chrome="hud"] .ace-diff {
+ --hud-cut: 8px;
+}
+:root[data-theme-chrome="hud"] .ace-diff .d2h-file-wrapper {
+ border-color: var(--d2h-border-color);
+ border-radius: 0;
+ background: var(--ace-code-bg);
+ clip-path: polygon(var(--hud-cut) 0, 100% 0, 100% calc(100% - var(--hud-cut)), calc(100% - var(--hud-cut)) 100%, 0 100%, 0 var(--hud-cut));
+}
+:root[data-theme-chrome="hud"] .ace-diff .d2h-file-header {
+ background:
+ linear-gradient(var(--ace-accent), var(--ace-accent)) left var(--hud-cut) top 0 / 42px 2px no-repeat,
+ var(--d2h-file-header-bg-color);
+ color: var(--ace-fg);
+ border-color: var(--d2h-file-header-border-color);
+}
+:root[data-theme-chrome="hud"] .ace-diff .d2h-file-name {
+ color: var(--ace-fg);
+ letter-spacing: .02em;
+}
+:root[data-theme-chrome="hud"] .ace-diff .d2h-file-name-wrapper svg,
+:root[data-theme-chrome="hud"] .ace-diff .d2h-icon {
+ fill: var(--ace-accent);
+}
+:root[data-theme-chrome="hud"] .ace-diff .d2h-tag {
+ background: transparent;
+ border-radius: 0;
+ padding: 0 5px;
+ letter-spacing: .12em;
+ text-transform: uppercase;
+}
+:root[data-theme-chrome="hud"] .ace-diff .d2h-changed-tag { color: var(--ace-accent); border-color: var(--ace-accent); }
+:root[data-theme-chrome="hud"] .ace-diff .d2h-added-tag { color: var(--ace-ok); border-color: var(--ace-ok); }
+:root[data-theme-chrome="hud"] .ace-diff .d2h-deleted-tag { color: var(--ace-danger); border-color: var(--ace-danger); }
+:root[data-theme-chrome="hud"] .ace-diff .d2h-moved-tag { color: var(--ace-send-bg); border-color: var(--ace-send-bg); }
+/* Code stays in the code color for readability; the +/- markers, the
+ left rail and the tinted rows carry the change state. */
+:root[data-theme-chrome="hud"] .ace-diff :is(.d2h-code-line, .d2h-code-side-line, .d2h-cntx) {
+ color: var(--ace-code-fg);
+}
+:root[data-theme-chrome="hud"] .ace-diff .d2h-cntx {
+ background: var(--ace-code-bg);
+}
+:root[data-theme-chrome="hud"] .ace-diff .d2h-ins,
+:root[data-theme-chrome="hud"] .ace-diff .d2h-ins.d2h-change {
+ background: var(--d2h-ins-bg-color);
+ color: var(--ace-code-fg);
+}
+:root[data-theme-chrome="hud"] .ace-diff .d2h-del,
+:root[data-theme-chrome="hud"] .ace-diff .d2h-del.d2h-change {
+ background: var(--d2h-del-bg-color);
+ color: var(--ace-fg-2);
+}
+:root[data-theme-chrome="hud"] .ace-diff .d2h-ins .d2h-code-line-prefix { color: var(--ace-ok); }
+:root[data-theme-chrome="hud"] .ace-diff .d2h-del .d2h-code-line-prefix { color: var(--ace-danger); }
+:root[data-theme-chrome="hud"] .ace-diff :is(.d2h-code-line, .d2h-code-side-line) :is(ins, del) {
+ color: var(--ace-fg);
+ border-radius: 0;
+}
+:root[data-theme-chrome="hud"] .ace-diff .d2h-info {
+ background: var(--d2h-info-bg-color);
+ color: var(--ace-fg-mute);
+ border-color: var(--d2h-info-border-color);
+ letter-spacing: .04em;
+}
+:root[data-theme-chrome="hud"] .ace-diff :is(.d2h-code-linenumber, .d2h-code-side-linenumber) {
+ background: rgba(var(--ace-surface-rgb), .65);
+ color: var(--ace-fg-mute);
+ border-color: var(--d2h-line-border-color);
+}
+:root[data-theme-chrome="hud"] .ace-diff :is(.d2h-code-linenumber, .d2h-code-side-linenumber).d2h-ins {
+ background: rgba(var(--ace-ok-rgb), .14);
+ color: var(--ace-ok);
+ box-shadow: inset 2px 0 0 var(--ace-ok);
+}
+:root[data-theme-chrome="hud"] .ace-diff :is(.d2h-code-linenumber, .d2h-code-side-linenumber).d2h-del {
+ background: rgba(var(--ace-danger-rgb), .14);
+ color: var(--ace-danger);
+ box-shadow: inset 2px 0 0 var(--ace-danger);
+}
+:root[data-theme-chrome="hud"] .ace-diff :is(.d2h-code-linenumber, .d2h-code-side-linenumber).d2h-info {
+ background: var(--d2h-info-bg-color);
+}
+:root[data-theme-chrome="hud"] .ace-diff .d2h-emptyplaceholder,
+:root[data-theme-chrome="hud"] .ace-diff .d2h-code-side-emptyplaceholder {
+ background: var(--d2h-empty-placeholder-bg-color);
+ border-color: var(--d2h-empty-placeholder-border-color);
+}
+
+/* Neon accents are bright: dark text keeps solid accent buttons readable. */
+:root[data-theme-chrome="hud"] .bg-accent.text-white {
+ color: var(--ace-bg);
+}
+
+/* ── Motion (only while the theme's effects switch is on) ─────────────── */
+@keyframes ace-hud-spin { to { transform: rotate(360deg); } }
+@keyframes ace-hud-spin-back { to { transform: rotate(-360deg); } }
+@keyframes ace-hud-flicker {
+ 0%, 89%, 91.5%, 94%, 100% { opacity: 1; }
+ 90% { opacity: .78; }
+ 92.5% { opacity: .9; }
+ 93% { opacity: .7; }
+}
+@keyframes ace-hud-pulse {
+ 0%, 100% { opacity: .78; }
+ 50% { opacity: 1; }
+}
+@keyframes ace-hud-glitch {
+ 0% { transform: translate(0, 0); clip-path: inset(0 0 0 0); }
+ 20% { transform: translate(-2px, 0); clip-path: inset(0 0 58% 0); }
+ 40% { transform: translate(2px, 0); clip-path: inset(42% 0 0 0); }
+ 60% { transform: translate(-1px, 0); clip-path: inset(20% 0 30% 0); }
+ 80% { transform: translate(1px, 0); clip-path: inset(0 0 0 0); }
+ 100% { transform: translate(0, 0); clip-path: inset(0 0 0 0); }
+}
+@keyframes ace-hud-title-in {
+ 0% { opacity: 0; transform: translateY(3px); }
+ 30% { opacity: .9; }
+ 38% { opacity: .2; }
+ 52% { opacity: 1; }
+ 60% { opacity: .5; }
+ 100% { opacity: 1; transform: none; }
+}
+/* Registered so the runner position interpolates; only the first background
+ layer reads it, the frame layers stay put. */
+@property --ace-hud-run {
+ syntax: "";
+ inherits: false;
+ initial-value: -30%;
+}
+@keyframes ace-hud-composer-run {
+ from { --ace-hud-run: -30%; }
+ to { --ace-hud-run: 130%; }
+}
+
+:root[data-theme-chrome="hud"][data-theme-effects="on"] .ace-home-content > .ace-home-logo::before {
+ animation: ace-hud-spin 18s linear infinite;
+}
+:root[data-theme-chrome="hud"][data-theme-effects="on"] .ace-home-content > .ace-home-logo::after {
+ animation: ace-hud-spin-back 9s linear infinite;
+}
+:root[data-theme-chrome="hud"][data-theme-effects="on"] .ace-home-title {
+ animation: ace-hud-title-in .9s ease-out both;
+}
+:root[data-theme-chrome="hud"][data-theme-effects="on"] .ace-session-panel::before {
+ will-change: opacity;
+ animation: ace-hud-flicker 11s linear infinite;
+}
+:root[data-theme-chrome="hud"][data-theme-effects="on"][data-theme-wallpaper="true"] .ace-app-shell[data-home-wallpaper="true"]::before {
+ will-change: opacity;
+ animation: ace-hud-flicker 15s linear 2s infinite;
+}
+:root[data-theme-chrome="hud"][data-theme-effects="on"] .ace-sidebar-session-row.is-tree-active::after {
+ animation: ace-hud-pulse 2.6s ease-in-out infinite;
+}
+:root[data-theme-chrome="hud"][data-theme-effects="on"] :is(.ace-sidebar-session-row, .ace-home-hint-card):hover::after {
+ animation: ace-hud-glitch .32s steps(2, end) 1;
+}
+:root[data-theme-chrome="hud"][data-theme-effects="on"] .ace-composer-card:focus-within::after,
+:root[data-theme-chrome="hud"][data-theme-effects="on"] .ace-composer-card:has(.ace-composer-send[data-composer-action="stop"])::after {
+ background-image:
+ linear-gradient(90deg, transparent, rgba(255, 255, 255, .85), transparent),
+ linear-gradient(var(--h), var(--h)), linear-gradient(var(--h), var(--h)), linear-gradient(var(--h), var(--h)),
+ linear-gradient(var(--h), var(--h)), linear-gradient(var(--h), var(--h)),
+ linear-gradient(var(--l), var(--l)), linear-gradient(var(--l), var(--l)), linear-gradient(var(--l), var(--l)),
+ linear-gradient(to bottom right, transparent calc(50% - .6px), var(--l) 0 calc(50% + .6px), transparent 0),
+ linear-gradient(to bottom left, transparent calc(50% - .6px), var(--l) 0 calc(50% + .6px), transparent 0),
+ linear-gradient(var(--l), var(--l)), linear-gradient(var(--l), var(--l)), linear-gradient(var(--l), var(--l)),
+ linear-gradient(to bottom left, transparent calc(50% - .6px), var(--l) 0 calc(50% + .6px), transparent 0),
+ linear-gradient(to bottom right, transparent calc(50% - .6px), var(--l) 0 calc(50% + .6px), transparent 0);
+ background-position:
+ var(--ace-hud-run) 100%,
+ left 0 top var(--n), right 0 top var(--n), center top, left var(--c) bottom 0, right var(--c) bottom 0,
+ left 0 top var(--n), right 0 top var(--n), center top,
+ calc(50% - var(--w) + var(--n) / 2) 0, calc(50% + var(--w) - var(--n) / 2) 0,
+ left 0 top var(--n), right 0 top var(--n), left var(--c) bottom 0,
+ left bottom, right bottom;
+ background-size:
+ 22% 2px,
+ 52px 2px, 52px 2px, 96px 2px, 34px 2px, 34px 2px,
+ calc(50% - var(--w)) 1px, calc(50% - var(--w)) 1px, calc(2 * var(--w) - 2 * var(--n)) 1px,
+ var(--n) var(--n), var(--n) var(--n),
+ 1px calc(100% - var(--n) - var(--c)), 1px calc(100% - var(--n) - var(--c)), calc(100% - 2 * var(--c)) 1px,
+ var(--c) var(--c), var(--c) var(--c);
+ background-repeat: no-repeat;
+ animation: ace-hud-composer-run 2.4s linear infinite;
+}
+
+/* ── Motion layer (components/HudEffects.jsx) ─────────────────────────── */
+.ace-hud-effects {
+ position: fixed;
+ inset: 0;
+ z-index: 90;
+ overflow: hidden;
+ pointer-events: none;
+}
+.ace-hud-effects-top {
+ z-index: 2147483000;
+}
+/* A soft band drifting down the window; transform-only so it stays on the
+ compositor and never repaints the page underneath. */
+.ace-hud-sweep {
+ position: absolute;
+ left: 0;
+ right: 0;
+ top: 0;
+ height: 160px;
+ background: linear-gradient(to bottom, transparent, rgba(var(--ace-accent-rgb), .05) 70%, rgba(var(--ace-accent-rgb), .11) 96%, transparent);
+ transform: translateY(-180px);
+ will-change: transform;
+ animation: ace-hud-sweep 36s linear infinite;
+}
+/* One pass takes 6s, then the band waits off-screen for a 30s cooldown. */
+@keyframes ace-hud-sweep {
+ 0% { transform: translateY(-180px); }
+ 16.667%, 100% { transform: translateY(100vh); }
+}
+.ace-hud-burst {
+ position: absolute;
+ inset: 0;
+ z-index: 400;
+}
+.ace-hud-boot {
+ background: rgba(var(--ace-bg-rgb), .55);
+ animation: ace-hud-boot-veil 1.3s steps(12, end) forwards;
+}
+@keyframes ace-hud-boot-veil {
+ 0% { opacity: 1; }
+ 20% { opacity: .55; }
+ 26% { opacity: .9; }
+ 48% { opacity: .35; }
+ 56% { opacity: .6; }
+ 100% { opacity: 0; }
+}
+.ace-hud-boot-line {
+ position: absolute;
+ left: 0;
+ right: 0;
+ top: 0;
+ height: 2px;
+ background: var(--ace-accent);
+ box-shadow: 0 0 12px 2px rgba(var(--ace-accent-rgb), .8), 0 -40px 60px rgba(var(--ace-accent-rgb), .18);
+ animation: ace-hud-boot-scan .95s cubic-bezier(.45, 0, .2, 1) forwards;
+}
+@keyframes ace-hud-boot-scan {
+ from { transform: translateY(0); opacity: 1; }
+ 92% { opacity: 1; }
+ to { transform: translateY(100vh); opacity: 0; }
+}
+.ace-hud-boot-label,
+.ace-hud-switch-card {
+ position: absolute;
+ left: 50%;
+ top: 50%;
+ transform: translate(-50%, -50%);
+ display: flex;
+ flex-direction: column;
+ align-items: center;
+ gap: 6px;
+ color: var(--ace-accent);
+ font: 600 12px/1.2 ui-monospace, "Cascadia Code", Consolas, monospace;
+ letter-spacing: .32em;
+ text-shadow: 0 0 10px rgba(var(--ace-accent-rgb), .7);
+}
+.ace-hud-boot-label strong,
+.ace-hud-switch-name {
+ font-size: 20px;
+ font-weight: 700;
+ line-height: 1.2;
+ letter-spacing: .2em;
+ color: var(--ace-fg);
+}
+.ace-hud-boot-label {
+ animation: ace-hud-label-flicker 1.3s linear forwards;
+}
+@keyframes ace-hud-label-flicker {
+ 0%, 12% { opacity: 0; }
+ 16% { opacity: 1; }
+ 20% { opacity: .2; }
+ 26%, 70% { opacity: 1; }
+ 100% { opacity: 0; }
+}
+.ace-hud-switch {
+ background: rgba(var(--ace-bg-rgb), .62);
+ animation: ace-hud-switch-veil 1.05s ease-out forwards;
+}
+@keyframes ace-hud-switch-veil {
+ 0% { opacity: 0; }
+ 8% { opacity: 1; }
+ 70% { opacity: 1; }
+ 100% { opacity: 0; }
+}
+.ace-hud-switch-bars i {
+ position: absolute;
+ left: -10%;
+ width: 120%;
+ height: 7px;
+ background: rgba(var(--ace-accent-rgb), .55);
+ box-shadow: 4px 0 0 rgba(var(--hud-alt-rgb, var(--ace-accent-rgb)), .45), -4px 0 0 rgba(255, 255, 255, .25);
+ animation: ace-hud-bar .5s steps(5, end) 2;
+}
+.ace-hud-switch-bars i:nth-child(1) { top: 18%; height: 3px; animation-delay: .02s; }
+.ace-hud-switch-bars i:nth-child(2) { top: 41%; height: 11px; animation-delay: .08s; }
+.ace-hud-switch-bars i:nth-child(3) { top: 63%; height: 5px; animation-delay: .04s; }
+.ace-hud-switch-bars i:nth-child(4) { top: 82%; height: 2px; animation-delay: .12s; }
+@keyframes ace-hud-bar {
+ 0% { transform: translateX(-6%); opacity: .9; }
+ 25% { transform: translateX(5%); opacity: .3; }
+ 50% { transform: translateX(-3%); opacity: 1; }
+ 75% { transform: translateX(8%); opacity: .4; }
+ 100% { transform: translateX(0); opacity: 0; }
+}
+.ace-hud-switch-card {
+ padding: 18px 34px 16px;
+ background: rgba(var(--ace-surface-rgb), .92);
+ clip-path: polygon(14px 0, 100% 0, 100% calc(100% - 14px), calc(100% - 14px) 100%, 0 100%, 0 14px);
+ box-shadow: inset 0 0 0 1px rgba(var(--ace-accent-rgb), .8);
+ animation: ace-hud-card-glitch .45s steps(3, end) 1;
+}
+@keyframes ace-hud-card-glitch {
+ 0% { transform: translate(-50%, -50%) skewX(-14deg); clip-path: inset(40% 0 40% 0); }
+ 50% { transform: translate(-49%, -50%) skewX(6deg); clip-path: inset(10% 0 22% 0); }
+ 100% { transform: translate(-50%, -50%); }
+}
+.ace-hud-switch-icon {
+ width: 40px;
+ height: 35px;
+ color: var(--ace-accent);
+ filter: drop-shadow(0 0 6px rgba(var(--ace-accent-rgb), .8));
+}
+/* While the agent runs, the bottom-edge runner speeds up: a live data stream. */
+:root[data-theme-chrome="hud"][data-theme-effects="on"] .ace-composer-card:has(.ace-composer-send[data-composer-action="stop"])::after {
+ animation-duration: .9s;
+}
diff --git a/web/src/theme.jsx b/web/src/theme.jsx
index 73c9c693..e6b5f047 100644
--- a/web/src/theme.jsx
+++ b/web/src/theme.jsx
@@ -6,15 +6,21 @@
// 持久化走 lib/usePreference.js — 与 view / sidePanelCollapsed / layout widths
// 共享同一份 try/catch 兜底逻辑。useTheme() 对外 API 不变。
-import { createContext, useCallback, useContext, useEffect, useLayoutEffect, useRef, useState } from 'react';
+import { createContext, useCallback, useContext, useEffect, useLayoutEffect, useMemo, useRef, useState } from 'react';
import { usePreference } from './lib/usePreference.js';
import { api } from './lib/api.js';
-import { EVA_THEME_ID, applyInstalledTheme, loadThemeResources, releaseThemeResource, revokeThemeResources, resolveThemeAppearance, validThemeDefinition } from './lib/themePackages.js';
+import {
+ EVA_THEME_ID, applyInstalledTheme, loadThemeResources, releaseThemeResource, resolveThemeVariant,
+ revokeThemeResources, resolveThemeAppearance, selectThemeVariant, themeChromeStyle, themeVariantOptions,
+ validThemeDefinition,
+} from './lib/themePackages.js';
import { pushWindowBackgroundColor } from './lib/desktopWindowBackground.js';
import { desktopTaskbarBadge } from './lib/desktopTaskbarBadge.js';
+import { HudEffects } from './components/HudEffects.jsx';
import {
effectiveAppearanceTheme,
initialAppearancePreferences,
+ validThemeVariantId,
} from './lib/appearancePreferences.js';
import {
COLOR_THEME_STORAGE_KEY,
@@ -25,18 +31,29 @@ import {
} from './lib/colorTheme.js';
const STORAGE_KEY = 'ace.theme';
+const THEME_VARIANT_STORAGE_KEY = 'ace.themeVariant';
+const THEME_EFFECTS_STORAGE_KEY = 'ace.themeEffects';
+const NO_VARIANTS = Object.freeze([]);
const ThemeCtx = createContext({
theme: 'light',
colorTheme: DEFAULT_COLOR_THEME,
appearance: null,
+ themeVariant: '',
+ themeEffects: true,
+ themeChrome: null,
+ themeVariantOptions: NO_VARIANTS,
+ activeThemeVariant: null,
toggle: () => {},
set: () => {},
setColorTheme: () => {},
+ setThemeVariant: () => {},
+ setThemeEffects: () => {},
prepareTheme: async () => {},
forgetTheme: () => {},
});
function isValidTheme(v) { return v === 'system' || v === 'light' || v === 'dark'; }
+function isValidEffects(v) { return v === true || v === false; }
export function ThemeProvider({ children }) {
// Desktop 会在模块执行前注入稳定配置;普通 WebUI 则以系统/默认值起步。
@@ -52,6 +69,18 @@ export function ThemeProvider({ children }) {
initialAppearance.colorTheme,
isValidColorTheme,
);
+ // Variant and motion follow the daemon like the other appearance values;
+ // localStorage only paints the first frame before the daemon answers.
+ const [themeVariant, setThemeVariantPreference] = usePreference(
+ THEME_VARIANT_STORAGE_KEY,
+ initialAppearance.themeVariant,
+ validThemeVariantId,
+ );
+ const [themeEffects, setThemeEffectsPreference] = usePreference(
+ THEME_EFFECTS_STORAGE_KEY,
+ initialAppearance.themeEffects,
+ isValidEffects,
+ );
const [systemTheme, setSystemTheme] = useState(() => effectiveAppearanceTheme('system'));
const [installedThemes, setInstalledThemes] = useState({});
const installedTheme = installedThemes[colorTheme] || null;
@@ -130,7 +159,11 @@ export function ThemeProvider({ children }) {
media.addEventListener?.('change', changed);
return () => media.removeEventListener?.('change', changed);
}, []);
- const appearance = installedTheme ? resolveThemeAppearance(installedTheme) : null;
+ const activeTheme = installedTheme?.id === colorTheme ? installedTheme : null;
+ const appearance = installedTheme ? resolveThemeAppearance(resolveThemeVariant(installedTheme, themeVariant)) : null;
+ const themeChrome = activeTheme ? themeChromeStyle(activeTheme) : null;
+ const variantOptions = useMemo(() => (activeTheme ? themeVariantOptions(activeTheme) : NO_VARIANTS), [activeTheme]);
+ const activeThemeVariant = activeTheme ? selectThemeVariant(activeTheme, themeVariant)?.id || null : null;
const theme = installedTheme?.mode || (colorTheme === EVA_THEME_ID ? 'light' : themeMode === 'system' ? systemTheme : themeMode);
useLayoutEffect(() => {
@@ -142,6 +175,7 @@ export function ThemeProvider({ children }) {
installedTheme?.id === colorTheme ? installedTheme : null,
installedTheme?.backgroundUrl,
installedTheme || {},
+ { variant: themeVariant, effects: themeEffects },
);
// 两个主题维度落地后 --ace-bg 的 computed 值同步可读;把 body 底色推给
// 桌面壳,native 换窗口打底色(快速 resize 的新暴露区域随主题,不闪黑/白)。
@@ -149,7 +183,7 @@ export function ThemeProvider({ children }) {
pushWindowBackgroundColor();
desktopTaskbarBadge.refresh();
return clearInstalledTheme;
- }, [colorTheme, theme, installedTheme]);
+ }, [colorTheme, theme, installedTheme, themeVariant, themeEffects]);
const toggle = useCallback(() => setTheme(theme === 'dark' ? 'light' : 'dark'), [setTheme, theme]);
const set = useCallback((t) => setTheme(isValidTheme(t) ? t : 'system'), [setTheme]);
@@ -157,10 +191,29 @@ export function ThemeProvider({ children }) {
(value) => setColorThemePreference(effectiveColorTheme(value)),
[setColorThemePreference],
);
+ const setThemeVariant = useCallback(
+ (value) => setThemeVariantPreference(validThemeVariantId(value) ? value : ''),
+ [setThemeVariantPreference],
+ );
+ const setThemeEffects = useCallback(
+ (value) => setThemeEffectsPreference(value !== false),
+ [setThemeEffectsPreference],
+ );
return (
-
+
{children}
+
);
}