diff --git a/CHANGELOG.md b/CHANGELOG.md
index 0c811f6c..6424a438 100644
--- a/CHANGELOG.md
+++ b/CHANGELOG.md
@@ -4,6 +4,14 @@
## [未发布]
+## [0.9.4] - 2026-09-09
+
+- 前端设计优化:升级蓝黑、电光青与少量紫色的 Cyber HUD 视觉,保留亮色主题;统一工作台、欢迎界面、共享控件、浮层和源码/调参窗口材质。
+- 恢复自调参平台左侧会话与 Trial、中间指标与排行、右侧决策与审批的三栏布局;窄屏纵向排列,保留审批与 Monaco Diff 操作。
+- 增加运行状态呼吸与交互微光,支持减少动态效果、强制色彩和无模糊能力回退;侧栏采用静态材质,避免大面积实时模糊影响仿真性能。
+- 优化欢迎页与源代码入口、执行器控件密度和重复参数提示,将训练地图操作文案统一为“同步场景地图”。
+- 补充双主题材质、状态动效、回退及三栏布局回归测试和设计交付说明;性能数据仅为无头 Chromium 短时特效开关对照,不代表真实 GPU 或重型模型基准。
+
## [0.9.3] - 2026-09-08
- 重构绿色主工作台:紧凑全局入口、可收起资源/上下文侧栏,统一状态、仿真/地图工具、草稿、摄像头与风险槽位;窄屏临时布局不覆盖桌面偏好。
diff --git a/package-lock.json b/package-lock.json
index c24f7520..6ee80ef1 100644
--- a/package-lock.json
+++ b/package-lock.json
@@ -1,12 +1,12 @@
{
"name": "mujoco-web-platform",
- "version": "0.9.3",
+ "version": "0.9.4",
"lockfileVersion": 3,
"requires": true,
"packages": {
"": {
"name": "mujoco-web-platform",
- "version": "0.9.3",
+ "version": "0.9.4",
"license": "Apache-2.0",
"dependencies": {
"@monaco-editor/react": "^4.7.0",
diff --git a/package.json b/package.json
index 9ac2bbd4..2e45619c 100644
--- a/package.json
+++ b/package.json
@@ -1,6 +1,6 @@
{
"name": "mujoco-web-platform",
- "version": "0.9.3",
+ "version": "0.9.4",
"description": "基于 MuJoCo WebAssembly 的本地机器人仿真与控制平台",
"private": true,
"type": "module",
diff --git a/web_platform/UI_CYBER_HUD.md b/web_platform/UI_CYBER_HUD.md
new file mode 100644
index 00000000..0578fd9a
--- /dev/null
+++ b/web_platform/UI_CYBER_HUD.md
@@ -0,0 +1,48 @@
+# Cyber HUD 界面样式
+
+本次直接升级现有 React 界面的 JSX/CSS,不增加静态演示入口、外部字体或依赖,保留亮色主题和所有仿真业务行为。
+
+## 源码
+
+- `src/styles.css`:蓝黑/电光青语义色、紫色装饰、玻璃材质、交互微光、状态呼吸和可访问性降级。
+- `src/components/ui/{Button,IconButton,Tabs,CollapsibleSection,Dialog,Popover}.tsx`:共享视觉挂点,保留原有键盘交互与 Portal。
+- `src/app/App.tsx` 及 `src/app/components/{WorkbenchHeader,SidebarPanel,ViewportHUD,WorkspaceOverlays,SourceEditorDialog}.tsx`:工作台外壳、欢迎卡片、HUD 和编辑窗口。
+- `src/tuning/{TuningApp,TuningConsole,TuningSessionRail,MetricsComparisonBoard}.tsx`:调参页、导航、指标卡片;Monaco 原生亮/暗代码主题不变。
+- `e2e/ui-cyber-hud.spec.ts`:新增 5 个材质、主题持久化、动效及回退测试。
+
+## v0.9.4 调参布局
+
+自调参会话页恢复三栏:左侧会话与 Trial、中间指标与排行、右侧决策与审批。宽度达到 1280px 时三栏同时显示并分别滚动,更窄窗口按上述顺序纵向排列。原有主题、审批与 Diff 编辑行为保持不变。
+
+## 材质与性能约束
+
+- 暗色基底 `#060914` / `#0B1224`,强调 `#36E4F2`,装饰 `#A78BFA`;亮色强调 `#087887`。
+- 毛玻璃仅用于顶栏、欢迎卡片、小型 HUD 和浮层;侧栏使用静态渐变质感,避免长列表滚动时大面积背景模糊。
+- 3.6 秒呼吸动效仅绑定真实仿真运行状态;装饰层不拦截指针。
+- 减少动态效果偏好禁用动画;无背景模糊支持时使用实色表面;强制色彩模式保留清晰边框。
+
+## 验证记录
+
+- `npm run typecheck`、`npm run lint`、`npm run build` 通过。
+- `npm run test`:97 个测试文件、408 项测试通过。
+- 工作台布局、共享浮层、领域面板、调参系统及新增 HUD 测试:59 项端到端测试通过。
+- 双主题覆盖 1920×1080、1440×900、1366×768、1024×768、768×800;检查了源码/Diff、弹层、主题同步和视口避让。
+- 变更源码的 Prettier 检查与 `git diff --check` 通过。
+- 构建仍提示部分包超过 500kB;本次未改变加载架构。
+
+性能取证:Headless Chromium、1440×900、24 刚体场景下按“关闭特效→开启→开启→关闭”采样,每段 2.5 秒并滚动侧栏。初版全侧栏模糊约 54 FPS,取消侧栏实时模糊后开/关特效均约 60 FPS,P95 帧间隔约 16.7ms。
+
+**限制**:这是短时特效开关对照,不是完整重构前后的严格基准,也不代表真实桌面 GPU 或重型模型表现;未执行 Firefox/Safari 实机验证。无模糊回退测试通过修改 CSS 能力分支来模拟,不等同于旧浏览器实测。
+
+## 本地证据
+
+- `../test-results/cyber-hud-delivery/workbench-dark.png`
+- `../test-results/cyber-hud-delivery/workbench-light.png`
+- `../test-results/cyber-hud-delivery/tuning-dark.png`
+- `../test-results/cyber-hud-delivery/tuning-light.png`
+- `../test-results/cyber-hud-delivery/before-dark.png`
+- `../test-results/cyber-hud-delivery/performance-final.json`
+
+截图与性能证据为本地生成产物,不纳入源码提交;再次运行 Playwright 可能清理 `test-results/`。详细命令日志保存在 `/tmp/cyber-hud-evidence/`。
+
+预览:`npm run dev`,主入口 `/`,独立调参入口 `/tuning.html`。
diff --git a/web_platform/e2e/app.spec.ts b/web_platform/e2e/app.spec.ts
index 1898b7c9..faf4693a 100644
--- a/web_platform/e2e/app.spec.ts
+++ b/web_platform/e2e/app.spec.ts
@@ -264,7 +264,6 @@ test('转换后的 MJCF 可编辑并重新载入', async ({ page }) => {
.first()
.setInputFiles({ name: 'model.xml', mimeType: 'text/xml', buffer: Buffer.from(SIMPLE_MODEL) });
await expect(page.getByRole('button', { name: '▶ 播放' })).toBeEnabled({ timeout: 30_000 });
- await page.getByRole('button', { name: '工程', exact: true }).click();
await page.getByRole('button', { name: '源代码' }).click();
const dialog = page.getByRole('dialog', { name: '转换后的 MJCF 编辑器' });
await expect(dialog).toBeVisible();
@@ -291,7 +290,6 @@ test('关闭已修改的 MJCF 前要求确认', async ({ page }) => {
.first()
.setInputFiles({ name: 'model.xml', mimeType: 'text/xml', buffer: Buffer.from(SIMPLE_MODEL) });
await expect(page.getByRole('button', { name: '▶ 播放' })).toBeEnabled({ timeout: 30_000 });
- await page.getByRole('button', { name: '工程', exact: true }).click();
await page.getByRole('button', { name: '源代码' }).click();
const editorDialog = page.getByRole('dialog', { name: '转换后的 MJCF 编辑器' });
await editorDialog.locator('.monaco-editor').click({ position: { x: 240, y: 120 } });
@@ -354,7 +352,6 @@ test('加载引用 OBJ 的 URDF 工程', async ({ page }) => {
await expect(notifications).toContainText(/URDF 已转换为 MJCF(浮动基座),并整体平移/);
await page.keyboard.press('Escape');
await expect(page.getByLabel('显示碰撞几何')).not.toBeChecked();
- await page.getByRole('button', { name: '工程', exact: true }).click();
await page.getByRole('button', { name: '源代码' }).click();
const sourceDialog = page.getByRole('dialog', { name: '转换后的 MJCF 编辑器' });
await expect(sourceDialog.getByText('缓存文件 · 可编辑')).toBeVisible();
@@ -434,7 +431,6 @@ test('转换后的 MJCF 保存时保留 DAE 转换缓存资源', async ({ page }
.getByRole('button', { name: '转换并加载' })
.click();
await expect(page.getByRole('button', { name: '▶ 播放' })).toBeEnabled({ timeout: 30_000 });
- await page.getByRole('button', { name: '工程', exact: true }).click();
await page.getByRole('button', { name: '源代码' }).click();
const dialog = page.getByRole('dialog', { name: '转换后的 MJCF 编辑器' });
await dialog.locator('.monaco-editor').click({ position: { x: 240, y: 120 } });
diff --git a/web_platform/e2e/ui-cyber-hud.spec.ts b/web_platform/e2e/ui-cyber-hud.spec.ts
new file mode 100644
index 00000000..700bb089
--- /dev/null
+++ b/web_platform/e2e/ui-cyber-hud.spec.ts
@@ -0,0 +1,114 @@
+import { expect, test } from '@playwright/test';
+
+const MODEL = `
+
+`;
+
+for (const theme of ['dark', 'light']) {
+ test(`Cyber HUD 材质与可读性 ${theme}`, async ({ page }, info) => {
+ await page.setViewportSize({ width: 1440, height: 900 });
+ await page.addInitScript((value) => {
+ if (!localStorage.getItem('mujoco-platform-theme')) {
+ localStorage.setItem('mujoco-platform-theme', value);
+ }
+ }, theme);
+ await page.goto('/');
+ const welcome = page.getByRole('region', { name: '导入模型工程' });
+ await expect(welcome).toBeVisible();
+ await expect(page.locator('.cyber-workspace')).toHaveClass(new RegExp(`theme-${theme}`));
+ await expect(welcome).toHaveCSS('backdrop-filter', 'blur(12px) saturate(1.2)');
+ const primary = page.getByRole('button', { name: '选择文件', exact: true });
+ await primary.focus();
+ await expect(primary).toBeFocused();
+ await expect(primary).toHaveCSS('outline-style', 'solid');
+ // 在真实浏览器中验证主按钮与正文的计算色对比度,而非仅比较 token。
+ const contrast = await primary.evaluate((button) => {
+ function luminance(color: string) {
+ const rgb = color
+ .match(/[\d.]+/g)!
+ .slice(0, 3)
+ .map(Number);
+ const linear = rgb.map((value) => {
+ const v = value / 255;
+ return v <= 0.04045 ? v / 12.92 : ((v + 0.055) / 1.055) ** 2.4;
+ });
+ return linear[0] * 0.2126 + linear[1] * 0.7152 + linear[2] * 0.0722;
+ }
+ const style = getComputedStyle(button);
+ const a = luminance(style.color),
+ b = luminance(style.backgroundColor);
+ return (Math.max(a, b) + 0.05) / (Math.min(a, b) + 0.05);
+ });
+ expect(contrast).toBeGreaterThanOrEqual(4.5);
+ const disabled = page.getByRole('button', { name: '源代码', exact: true });
+ await expect(disabled).toBeDisabled();
+ await expect(disabled).toHaveCSS('box-shadow', 'none');
+ await page.screenshot({
+ path: info.outputPath(`workbench-${theme}.png`),
+ animations: 'disabled',
+ });
+ await page.goto('/tuning.html');
+ await expect(page.locator('.cyber-workspace')).toHaveClass(new RegExp(`theme-${theme}`));
+ await page
+ .getByRole('button', { name: `切换到${theme === 'dark' ? '白天' : '黑夜'}主题` })
+ .click();
+ await page.reload();
+ await expect(page.locator('.cyber-workspace')).toHaveClass(
+ new RegExp(`theme-${theme === 'dark' ? 'light' : 'dark'}`),
+ );
+ });
+}
+
+test('运行状态呼吸遵循减少动态效果,装饰不拦截交互', async ({ page }) => {
+ await page.goto('/');
+ await page.locator('#mujoco-project-files').setInputFiles({
+ name: 'hud.xml',
+ mimeType: 'text/xml',
+ buffer: Buffer.from(MODEL),
+ });
+ const play = page.getByRole('button', { name: '▶ 播放' });
+ await expect(play).toBeEnabled({ timeout: 30_000 });
+ await play.click();
+ const signal = page.locator('.hud-signal');
+ await expect(signal).toHaveAttribute('data-running', 'true');
+ const animation = () => signal.evaluate((el) => getComputedStyle(el, '::after').animationName);
+ expect(await animation()).toBe('hud-breathe');
+ expect(await signal.evaluate((el) => getComputedStyle(el, '::after').pointerEvents)).toBe('none');
+ await page.emulateMedia({ reducedMotion: 'reduce' });
+ await expect.poll(animation).toBe('none');
+ await page.getByRole('button', { name: '⏸ 暂停' }).click();
+ await expect(signal).toHaveAttribute('data-running', 'false');
+ await page.emulateMedia({ forcedColors: 'active' });
+ await expect(page.locator('.engineering-glass').first()).toHaveCSS('backdrop-filter', 'none');
+});
+
+for (const theme of ['dark', 'light']) {
+ test(`无模糊能力时使用实色回退 ${theme}`, async ({ page }) => {
+ // Chromium 支持模糊:使构建 CSS 的能力分支为 false,验证回退规则本身。
+ let replaced = false;
+ await page.route('**/*.css', async (route) => {
+ const response = await route.fetch();
+ const css = await response.text();
+ const fallback = css.replace(/backdrop-filter:\s*blur\(1px\)/g, 'cyber-unsupported: none');
+ replaced ||= css !== fallback;
+ await route.fulfill({ response, body: fallback });
+ });
+ await page.addInitScript(
+ (value) => localStorage.setItem('mujoco-platform-theme', value),
+ theme,
+ );
+ await page.goto('/');
+ const welcome = page.getByRole('region', { name: '导入模型工程' });
+ await expect(welcome).toBeVisible();
+ expect(replaced).toBe(true);
+ await expect(welcome).toHaveCSS('backdrop-filter', 'none');
+ await expect(welcome).toHaveCSS(
+ 'background-color',
+ theme === 'dark' ? 'rgb(11, 18, 36)' : 'rgb(251, 252, 254)',
+ );
+ await page.getByRole('button', { name: '打开命令面板' }).click();
+ await expect(page.getByRole('dialog', { name: '命令面板' })).toBeVisible();
+ await page.keyboard.press('Escape');
+ await expect(page.getByRole('dialog', { name: '命令面板' })).toBeHidden();
+ });
+}
diff --git a/web_platform/e2e/ui-tuning-system.spec.ts b/web_platform/e2e/ui-tuning-system.spec.ts
index ee2b2221..332e6afd 100644
--- a/web_platform/e2e/ui-tuning-system.spec.ts
+++ b/web_platform/e2e/ui-tuning-system.spec.ts
@@ -72,7 +72,24 @@ for (const theme of ['dark', 'light'] as const)
await page.getByRole('button', { name: '连接/刷新' }).click();
await expect(page.getByRole('img', { name: /收敛曲线/ })).toBeVisible();
await expect(page.getByRole('button', { name: '停止 Session' })).toBeVisible();
- await expect(page.getByRole('button', { name: /决策与审批 · 待审批 1/ })).toBeVisible();
+ const sessions = page.getByRole('region', { name: '会话与 Trial' });
+ const metrics = page.getByRole('main', { name: '指标与排行' });
+ const decisions = page.getByRole('complementary', { name: '决策与审批' });
+ await expect(sessions).toBeVisible();
+ await expect(metrics).toBeVisible();
+ await expect(decisions).toBeVisible();
+ const left = (await sessions.boundingBox())!;
+ const center = (await metrics.boundingBox())!;
+ const right = (await decisions.boundingBox())!;
+ if (width >= 1280) {
+ expect(Math.abs(left.y - center.y)).toBeLessThan(2);
+ expect(Math.abs(center.y - right.y)).toBeLessThan(2);
+ expect(left.x + left.width).toBeLessThanOrEqual(center.x + 1);
+ expect(center.x + center.width).toBeLessThanOrEqual(right.x + 1);
+ } else {
+ expect(left.y + left.height).toBeLessThanOrEqual(center.y + 1);
+ expect(center.y + center.height).toBeLessThanOrEqual(right.y + 1);
+ }
await page.getByRole('button', { name: '收敛曲线放大', exact: true }).click();
await page
.getByRole('button', { name: `切换到${theme === 'dark' ? '白天' : '黑夜'}主题` })
@@ -88,12 +105,11 @@ for (const theme of ['dark', 'light'] as const)
await page.getByRole('heading', { name: 'Trial Leaderboard' }).scrollIntoViewIfNeeded();
await noOverflow(page);
await page.screenshot({ path: info.outputPath('tuning-leaderboard.png') });
- await page.getByRole('button', { name: '会话与 Trial' }).click();
+ await page.getByRole('button', { name: '导入', exact: true }).scrollIntoViewIfNeeded();
await expect(page.getByRole('button', { name: '导入', exact: true })).toBeVisible();
await expect(page.getByText('导入会替换主工作台策略;无主窗口时下载文件。')).toBeVisible();
await noOverflow(page);
await page.screenshot({ path: info.outputPath('tuning-sessions.png') });
- await page.getByRole('button', { name: /决策与审批/ }).click();
await page.getByRole('button', { name: 'Monaco Diff 审查' }).scrollIntoViewIfNeeded();
await noOverflow(page);
await page.screenshot({ path: info.outputPath('tuning-decisions.png') });
@@ -204,7 +220,6 @@ for (const theme of ['dark', 'light'] as const)
await expect(page.getByRole('dialog', { name: '诊断与事件日志' })).toBeVisible();
await page.screenshot({ path: info.outputPath('diagnostics.png') });
await page.keyboard.press('Escape');
- await page.getByRole('button', { name: '工程', exact: true }).click();
await page.getByRole('button', { name: '源代码', exact: true }).click();
const source = page.getByRole('dialog', { name: '转换后的 MJCF 编辑器' });
await expect(source.locator('.monaco-editor')).toBeVisible();
diff --git a/web_platform/src/app/App.tsx b/web_platform/src/app/App.tsx
index 84d5db59..1e8a74b2 100644
--- a/web_platform/src/app/App.tsx
+++ b/web_platform/src/app/App.tsx
@@ -2492,7 +2492,7 @@ export function App() {
return (
{
it('空工作区解释导入到仿真的三步流程', () => {
render(
);
expect(screen.getByRole('region', { name: '导入模型工程' })).toBeVisible();
- fireEvent.click(screen.getByText('导入与仿真流程'));
+ expect(screen.getByText('Local Simulation Workspace')).toBeVisible();
+ expect(screen.getByText('本地解析、编译并运行机器人模型,无需上传资源')).toBeVisible();
+ expect(screen.getByLabelText('支持格式')).toHaveTextContent('OBJ / STL / DAE');
+ expect(screen.getByRole('button', { name: '选择文件' })).toBeVisible();
+ expect(screen.getByRole('button', { name: '选择文件夹' })).toBeVisible();
+ expect(screen.queryByText('导入与仿真流程')).not.toBeInTheDocument();
expect(screen.getByRole('list', { name: '仿真工作流程' })).toHaveTextContent('导入');
expect(screen.getByRole('list', { name: '仿真工作流程' })).toHaveTextContent('检查与配置');
expect(screen.getByRole('list', { name: '仿真工作流程' })).toHaveTextContent('运行与调试');
diff --git a/web_platform/src/app/components/SidebarPanel.tsx b/web_platform/src/app/components/SidebarPanel.tsx
index 74dfd2e1..b1715135 100644
--- a/web_platform/src/app/components/SidebarPanel.tsx
+++ b/web_platform/src/app/components/SidebarPanel.tsx
@@ -18,7 +18,7 @@ export function SidebarPanel({
return (