feat(web-platform): release V0.9.4 前端设计优化
web-platform-ci / TypeScript, lint, unit, build (push) Has been cancelled
web-platform-ci / Playwright E2E (push) Has been cancelled
web-platform-ci / TypeScript, lint, unit, build (pull_request) Has been cancelled
web-platform-ci / Playwright E2E (pull_request) Has been cancelled

This commit is contained in:
2026-09-09 13:15:46 +08:00
parent 13e35be98b
commit cb3fb47561
31 changed files with 563 additions and 182 deletions
+8
View File
@@ -4,6 +4,14 @@
## [未发布] ## [未发布]
## [0.9.4] - 2026-09-09
- 前端设计优化:升级蓝黑、电光青与少量紫色的 Cyber HUD 视觉,保留亮色主题;统一工作台、欢迎界面、共享控件、浮层和源码/调参窗口材质。
- 恢复自调参平台左侧会话与 Trial、中间指标与排行、右侧决策与审批的三栏布局;窄屏纵向排列,保留审批与 Monaco Diff 操作。
- 增加运行状态呼吸与交互微光,支持减少动态效果、强制色彩和无模糊能力回退;侧栏采用静态材质,避免大面积实时模糊影响仿真性能。
- 优化欢迎页与源代码入口、执行器控件密度和重复参数提示,将训练地图操作文案统一为“同步场景地图”。
- 补充双主题材质、状态动效、回退及三栏布局回归测试和设计交付说明;性能数据仅为无头 Chromium 短时特效开关对照,不代表真实 GPU 或重型模型基准。
## [0.9.3] - 2026-09-08 ## [0.9.3] - 2026-09-08
- 重构绿色主工作台:紧凑全局入口、可收起资源/上下文侧栏,统一状态、仿真/地图工具、草稿、摄像头与风险槽位;窄屏临时布局不覆盖桌面偏好。 - 重构绿色主工作台:紧凑全局入口、可收起资源/上下文侧栏,统一状态、仿真/地图工具、草稿、摄像头与风险槽位;窄屏临时布局不覆盖桌面偏好。
+2 -2
View File
@@ -1,12 +1,12 @@
{ {
"name": "mujoco-web-platform", "name": "mujoco-web-platform",
"version": "0.9.3", "version": "0.9.4",
"lockfileVersion": 3, "lockfileVersion": 3,
"requires": true, "requires": true,
"packages": { "packages": {
"": { "": {
"name": "mujoco-web-platform", "name": "mujoco-web-platform",
"version": "0.9.3", "version": "0.9.4",
"license": "Apache-2.0", "license": "Apache-2.0",
"dependencies": { "dependencies": {
"@monaco-editor/react": "^4.7.0", "@monaco-editor/react": "^4.7.0",
+1 -1
View File
@@ -1,6 +1,6 @@
{ {
"name": "mujoco-web-platform", "name": "mujoco-web-platform",
"version": "0.9.3", "version": "0.9.4",
"description": "基于 MuJoCo WebAssembly 的本地机器人仿真与控制平台", "description": "基于 MuJoCo WebAssembly 的本地机器人仿真与控制平台",
"private": true, "private": true,
"type": "module", "type": "module",
+48
View File
@@ -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`
-4
View File
@@ -264,7 +264,6 @@ test('转换后的 MJCF 可编辑并重新载入', async ({ page }) => {
.first() .first()
.setInputFiles({ name: 'model.xml', mimeType: 'text/xml', buffer: Buffer.from(SIMPLE_MODEL) }); .setInputFiles({ name: 'model.xml', mimeType: 'text/xml', buffer: Buffer.from(SIMPLE_MODEL) });
await expect(page.getByRole('button', { name: '▶ 播放' })).toBeEnabled({ timeout: 30_000 }); await expect(page.getByRole('button', { name: '▶ 播放' })).toBeEnabled({ timeout: 30_000 });
await page.getByRole('button', { name: '工程', exact: true }).click();
await page.getByRole('button', { name: '源代码' }).click(); await page.getByRole('button', { name: '源代码' }).click();
const dialog = page.getByRole('dialog', { name: '转换后的 MJCF 编辑器' }); const dialog = page.getByRole('dialog', { name: '转换后的 MJCF 编辑器' });
await expect(dialog).toBeVisible(); await expect(dialog).toBeVisible();
@@ -291,7 +290,6 @@ test('关闭已修改的 MJCF 前要求确认', async ({ page }) => {
.first() .first()
.setInputFiles({ name: 'model.xml', mimeType: 'text/xml', buffer: Buffer.from(SIMPLE_MODEL) }); .setInputFiles({ name: 'model.xml', mimeType: 'text/xml', buffer: Buffer.from(SIMPLE_MODEL) });
await expect(page.getByRole('button', { name: '▶ 播放' })).toBeEnabled({ timeout: 30_000 }); await expect(page.getByRole('button', { name: '▶ 播放' })).toBeEnabled({ timeout: 30_000 });
await page.getByRole('button', { name: '工程', exact: true }).click();
await page.getByRole('button', { name: '源代码' }).click(); await page.getByRole('button', { name: '源代码' }).click();
const editorDialog = page.getByRole('dialog', { name: '转换后的 MJCF 编辑器' }); const editorDialog = page.getByRole('dialog', { name: '转换后的 MJCF 编辑器' });
await editorDialog.locator('.monaco-editor').click({ position: { x: 240, y: 120 } }); 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 expect(notifications).toContainText(/URDF 已转换为 MJCF(浮动基座),并整体平移/);
await page.keyboard.press('Escape'); await page.keyboard.press('Escape');
await expect(page.getByLabel('显示碰撞几何')).not.toBeChecked(); await expect(page.getByLabel('显示碰撞几何')).not.toBeChecked();
await page.getByRole('button', { name: '工程', exact: true }).click();
await page.getByRole('button', { name: '源代码' }).click(); await page.getByRole('button', { name: '源代码' }).click();
const sourceDialog = page.getByRole('dialog', { name: '转换后的 MJCF 编辑器' }); const sourceDialog = page.getByRole('dialog', { name: '转换后的 MJCF 编辑器' });
await expect(sourceDialog.getByText('缓存文件 · 可编辑')).toBeVisible(); await expect(sourceDialog.getByText('缓存文件 · 可编辑')).toBeVisible();
@@ -434,7 +431,6 @@ test('转换后的 MJCF 保存时保留 DAE 转换缓存资源', async ({ page }
.getByRole('button', { name: '转换并加载' }) .getByRole('button', { name: '转换并加载' })
.click(); .click();
await expect(page.getByRole('button', { name: '▶ 播放' })).toBeEnabled({ timeout: 30_000 }); await expect(page.getByRole('button', { name: '▶ 播放' })).toBeEnabled({ timeout: 30_000 });
await page.getByRole('button', { name: '工程', exact: true }).click();
await page.getByRole('button', { name: '源代码' }).click(); await page.getByRole('button', { name: '源代码' }).click();
const dialog = page.getByRole('dialog', { name: '转换后的 MJCF 编辑器' }); const dialog = page.getByRole('dialog', { name: '转换后的 MJCF 编辑器' });
await dialog.locator('.monaco-editor').click({ position: { x: 240, y: 120 } }); await dialog.locator('.monaco-editor').click({ position: { x: 240, y: 120 } });
+114
View File
@@ -0,0 +1,114 @@
import { expect, test } from '@playwright/test';
const MODEL = `<mujoco model="hud"><worldbody><light pos="0 0 3"/>
<geom type="plane" size="3 3 .1"/><body pos="0 0 1"><freejoint/>
<geom type="box" size=".2 .2 .2" mass="1"/></body></worldbody></mujoco>`;
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();
});
}
+19 -4
View File
@@ -72,7 +72,24 @@ for (const theme of ['dark', 'light'] as const)
await page.getByRole('button', { name: '连接/刷新' }).click(); await page.getByRole('button', { name: '连接/刷新' }).click();
await expect(page.getByRole('img', { name: /收敛曲线/ })).toBeVisible(); await expect(page.getByRole('img', { name: /收敛曲线/ })).toBeVisible();
await expect(page.getByRole('button', { name: '停止 Session' })).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: '收敛曲线放大', exact: true }).click();
await page await page
.getByRole('button', { name: `切换到${theme === 'dark' ? '白天' : '黑夜'}主题` }) .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 page.getByRole('heading', { name: 'Trial Leaderboard' }).scrollIntoViewIfNeeded();
await noOverflow(page); await noOverflow(page);
await page.screenshot({ path: info.outputPath('tuning-leaderboard.png') }); 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.getByRole('button', { name: '导入', exact: true })).toBeVisible();
await expect(page.getByText('导入会替换主工作台策略;无主窗口时下载文件。')).toBeVisible(); await expect(page.getByText('导入会替换主工作台策略;无主窗口时下载文件。')).toBeVisible();
await noOverflow(page); await noOverflow(page);
await page.screenshot({ path: info.outputPath('tuning-sessions.png') }); await page.screenshot({ path: info.outputPath('tuning-sessions.png') });
await page.getByRole('button', { name: /决策与审批/ }).click();
await page.getByRole('button', { name: 'Monaco Diff 审查' }).scrollIntoViewIfNeeded(); await page.getByRole('button', { name: 'Monaco Diff 审查' }).scrollIntoViewIfNeeded();
await noOverflow(page); await noOverflow(page);
await page.screenshot({ path: info.outputPath('tuning-decisions.png') }); 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 expect(page.getByRole('dialog', { name: '诊断与事件日志' })).toBeVisible();
await page.screenshot({ path: info.outputPath('diagnostics.png') }); await page.screenshot({ path: info.outputPath('diagnostics.png') });
await page.keyboard.press('Escape'); await page.keyboard.press('Escape');
await page.getByRole('button', { name: '工程', exact: true }).click();
await page.getByRole('button', { name: '源代码', exact: true }).click(); await page.getByRole('button', { name: '源代码', exact: true }).click();
const source = page.getByRole('dialog', { name: '转换后的 MJCF 编辑器' }); const source = page.getByRole('dialog', { name: '转换后的 MJCF 编辑器' });
await expect(source.locator('.monaco-editor')).toBeVisible(); await expect(source.locator('.monaco-editor')).toBeVisible();
+1 -1
View File
@@ -2492,7 +2492,7 @@ export function App() {
return ( return (
<div <div
ref={root} ref={root}
className={`${theme === 'light' ? 'theme-light' : 'theme-dark'} flex h-screen min-w-0 flex-col overflow-hidden bg-app text-text-primary`} className={`${theme === 'light' ? 'theme-light' : 'theme-dark'} cyber-workspace flex h-screen min-w-0 flex-col overflow-hidden bg-app text-text-primary`}
onDragEnter={dragEnter} onDragEnter={dragEnter}
onDragLeave={dragLeave} onDragLeave={dragLeave}
onDragOver={dragOver} onDragOver={dragOver}
@@ -27,7 +27,12 @@ describe('第二批工作台组件', () => {
it('空工作区解释导入到仿真的三步流程', () => { it('空工作区解释导入到仿真的三步流程', () => {
render(<EmptyWorkspace />); render(<EmptyWorkspace />);
expect(screen.getByRole('region', { name: '导入模型工程' })).toBeVisible(); 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('检查与配置'); expect(screen.getByRole('list', { name: '仿真工作流程' })).toHaveTextContent('检查与配置');
expect(screen.getByRole('list', { name: '仿真工作流程' })).toHaveTextContent('运行与调试'); expect(screen.getByRole('list', { name: '仿真工作流程' })).toHaveTextContent('运行与调试');
@@ -18,7 +18,7 @@ export function SidebarPanel({
return ( return (
<ResizablePanel side={side} storageKey={`mujoco-${side}-sidebar-width`} visible={visible}> <ResizablePanel side={side} storageKey={`mujoco-${side}-sidebar-width`} visible={visible}>
<aside <aside
className={`flex h-full w-full min-w-0 flex-col overflow-hidden bg-panel ${side === 'left' ? 'border-r' : 'border-l'} border-border-subtle `} className={`cyber-sidebar flex h-full w-full min-w-0 flex-col overflow-hidden ${side === 'left' ? 'border-r' : 'border-l'} border-border-subtle `}
> >
<h2 className="flex h-9 shrink-0 items-center gap-2 border-b border-border-subtle px-3 text-xs font-semibold text-text-primary"> <h2 className="flex h-9 shrink-0 items-center gap-2 border-b border-border-subtle px-3 text-xs font-semibold text-text-primary">
<span className="text-accent [&>svg]:h-4 [&>svg]:w-4"> <span className="text-accent [&>svg]:h-4 [&>svg]:w-4">
@@ -146,7 +146,7 @@ export function SourceEditorDialog({
height: 'min(650px, calc(100vh - 32px))', height: 'min(650px, calc(100vh - 32px))',
} }
} }
className={`source-editor-window pointer-events-auto fixed flex min-h-64 min-w-0 max-w-[calc(100vw-32px)] max-h-[calc(100vh-32px)] flex-col overflow-hidden border border-border-strong bg-panel shadow-2xl ${maximized ? 'inset-0 h-full w-full' : 'resize'}`} className={`source-editor-window pointer-events-auto fixed flex min-h-64 min-w-0 max-w-[calc(100vw-32px)] max-h-[calc(100vh-32px)] flex-col overflow-hidden border border-border-strong ${maximized ? 'inset-0 h-full w-full' : 'resize'}`}
> >
<header <header
className="flex min-h-11 flex-wrap shrink-0 py-2 cursor-move select-none items-center gap-3 border-b border-border bg-surface px-3" className="flex min-h-11 flex-wrap shrink-0 py-2 cursor-move select-none items-center gap-3 border-b border-border bg-surface px-3"
@@ -9,9 +9,10 @@ export function ViewportHUD() {
return ( return (
<div <div
aria-label="视口状态" aria-label="视口状态"
className="flex flex-wrap items-center gap-2 rounded-lg border border-border bg-panel/90 px-2 py-1 text-xs shadow-sm" className="engineering-glass flex flex-wrap items-center gap-2 rounded-lg border border-border px-2 py-1 text-xs"
> >
<Badge tone={!ready || paused ? 'neutral' : 'success'}> <Badge tone={!ready || paused ? 'neutral' : 'success'}>
<span aria-hidden="true" className="hud-signal" data-running={ready && !paused} />
{!ready || paused ? ( {!ready || paused ? (
<CirclePause className="h-3.5 w-3.5" /> <CirclePause className="h-3.5 w-3.5" />
) : ( ) : (
@@ -2,7 +2,7 @@ import { fireEvent, render, screen } from '@testing-library/react';
import { WorkbenchHeader } from './WorkbenchHeader'; import { WorkbenchHeader } from './WorkbenchHeader';
const fn = () => {}; const fn = () => {};
describe('WorkbenchHeader', () => { describe('WorkbenchHeader', () => {
it('保留导入、工程菜单、命令与侧栏入口,不再重复仿真控制', () => { it('保留导入、源代码直达、命令与侧栏入口,不再重复仿真控制', () => {
const source = vi.fn(), const source = vi.fn(),
files = vi.fn(), files = vi.fn(),
left = vi.fn(); left = vi.fn();
@@ -26,8 +26,8 @@ describe('WorkbenchHeader', () => {
}); });
expect(files).toHaveBeenCalledOnce(); expect(files).toHaveBeenCalledOnce();
expect(screen.getByLabelText('打开文件夹')).toHaveAttribute('webkitdirectory'); expect(screen.getByLabelText('打开文件夹')).toHaveAttribute('webkitdirectory');
expect(screen.queryByRole('button', { name: '源代码' })).not.toBeInTheDocument(); expect(screen.getByRole('button', { name: '源代码' })).toBeVisible();
fireEvent.click(screen.getByRole('button', { name: '工程' })); expect(screen.queryByRole('button', { name: '工程' })).not.toBeInTheDocument();
fireEvent.click(screen.getByRole('button', { name: '源代码' })); fireEvent.click(screen.getByRole('button', { name: '源代码' }));
expect(source).toHaveBeenCalledOnce(); expect(source).toHaveBeenCalledOnce();
fireEvent.click(screen.getByRole('button', { name: '隐藏工程面板' })); fireEvent.click(screen.getByRole('button', { name: '隐藏工程面板' }));
@@ -1,8 +1,8 @@
import type { ChangeEvent, ReactNode } from 'react'; import type { ChangeEvent, ReactNode } from 'react';
import { Boxes, Code2, FolderOpen, PanelLeft, PanelRight, Search, Upload } from 'lucide-react'; import { Boxes, Code2, FolderOpen, PanelLeft, PanelRight, Search, Upload } from 'lucide-react';
import { IconButton, Popover } from '../../components/ui'; import { IconButton } from '../../components/ui';
const fileActionClass = const fileActionClass =
'inline-flex h-8 cursor-pointer items-center gap-1.5 rounded-md border border-border bg-surface/80 px-2 text-xs font-medium text-text-primary shadow-sm transition-[background-color,border-color,transform] hover:-translate-y-px hover:border-border-strong hover:bg-element-hover focus-within:ring-2 focus-within:ring-accent/30'; 'cyber-action inline-flex h-8 cursor-pointer items-center gap-1.5 rounded-md border border-border bg-surface/80 px-2 text-xs font-medium text-text-primary shadow-sm transition-[background-color,border-color,transform] hover:-translate-y-px hover:border-border-strong hover:bg-element-hover focus-within:ring-2 focus-within:ring-accent/30';
const fileActionLabelClass = 'whitespace-nowrap'; const fileActionLabelClass = 'whitespace-nowrap';
export function WorkbenchHeader({ export function WorkbenchHeader({
loading, loading,
@@ -32,7 +32,7 @@ export function WorkbenchHeader({
onCommands: () => void; onCommands: () => void;
}) { }) {
return ( return (
<header className="workbench-header relative z-40 flex h-11 shrink-0 justify-between items-center gap-2 border-b border-border px-2.5 shadow-[0_1px_0_rgba(255,255,255,0.02)] backdrop-blur-md"> <header className="workbench-header relative z-40 flex h-11 shrink-0 justify-between items-center gap-2 border-b border-border px-2.5 ">
<div className="flex min-w-0 items-center gap-1"> <div className="flex min-w-0 items-center gap-1">
<div className="mr-2 flex items-center gap-2"> <div className="mr-2 flex items-center gap-2">
<span className="grid h-7 w-7 place-items-center rounded-lg border border-accent/25 bg-accent-soft text-accent shadow-sm"> <span className="grid h-7 w-7 place-items-center rounded-lg border border-accent/25 bg-accent-soft text-accent shadow-sm">
@@ -76,33 +76,16 @@ export function WorkbenchHeader({
onChange={onFolder} onChange={onFolder}
/> />
</label> </label>
<Popover
label="工程菜单"
placement="bottom-left"
trigger={({ toggle }) => (
<button type="button" className={fileActionClass} onClick={toggle}>
</button>
)}
>
{({ close }) => (
<div className="w-52 rounded-lg border border-border bg-surface-elevated p-1 shadow-xl">
<button <button
type="button" type="button"
className="flex h-8 w-full items-center gap-2 rounded px-2 text-xs hover:bg-element-hover disabled:opacity-40" className={`${fileActionClass} disabled:pointer-events-none disabled:opacity-40`}
disabled={!hasProject || loading} disabled={!hasProject || loading}
onClick={() => { onClick={onOpenSource}
close();
onOpenSource?.();
}}
> >
<Code2 className="h-4 w-4" /> <Code2 aria-hidden="true" className="h-3.5 w-3.5" />
<span className={fileActionLabelClass}></span>
</button> </button>
</div> </div>
)}
</Popover>
</div>
<div className="flex min-w-0 items-center justify-end gap-0.5"> <div className="flex min-w-0 items-center justify-end gap-0.5">
<IconButton <IconButton
tooltip={leftOpen ? '隐藏工程面板' : '显示工程面板'} tooltip={leftOpen ? '隐藏工程面板' : '显示工程面板'}
@@ -6,6 +6,8 @@ import {
LoaderCircle, LoaderCircle,
PlayCircle, PlayCircle,
Settings2, Settings2,
ShieldCheck,
Sparkles,
Upload, Upload,
UploadCloud, UploadCloud,
} from 'lucide-react'; } from 'lucide-react';
@@ -39,11 +41,37 @@ export function EmptyWorkspace({ compact = false }: { compact?: boolean }) {
return ( return (
<section <section
aria-label="导入模型工程" aria-label="导入模型工程"
className="mx-auto w-full max-w-md rounded-lg border border-border bg-panel p-6 text-center" className="workspace-welcome relative w-full max-w-[600px] overflow-hidden rounded-[22px] border border-border-strong px-6 py-6 text-center sm:px-7"
> >
<h2 className="text-base font-semibold"></h2> <div aria-hidden="true" className="welcome-glow" />
<p className="mt-2 text-xs text-text-tertiary"> MJCF/XMLURDF ZIP</p> <div className="relative">
<div className="mt-4 flex flex-wrap justify-center gap-2"> <div className="mb-4 flex items-center justify-center gap-2 text-[10px] font-semibold uppercase tracking-[0.18em] text-accent">
<Sparkles className="h-3.5 w-3.5" />
Local Simulation Workspace
</div>
<span className="mx-auto mb-4 grid h-14 w-14 place-items-center rounded-2xl border border-accent/25 bg-accent-soft text-accent tool-active-glow">
<UploadCloud className="h-6 w-6" />
</span>
<h2 className="text-lg font-semibold tracking-tight text-text-primary">
</h2>
<p className="mt-1.5 text-xs leading-5 text-text-tertiary">
</p>
<div
className="mt-4 flex flex-wrap items-center justify-center gap-1.5"
aria-label="支持格式"
>
{['MJCF / XML', 'URDF', 'ZIP', 'OBJ / STL / DAE'].map((format) => (
<span
key={format}
className="rounded-full border border-border bg-surface/80 px-2.5 py-1 text-[10px] font-medium text-text-secondary"
>
{format}
</span>
))}
</div>
<div className="mt-5 flex flex-wrap items-center justify-center gap-2">
<Button <Button
variant="primary" variant="primary"
icon={<Upload className="h-4 w-4" />} icon={<Upload className="h-4 w-4" />}
@@ -58,17 +86,33 @@ export function EmptyWorkspace({ compact = false }: { compact?: boolean }) {
</Button> </Button>
</div> </div>
<details className="domain-details mt-4 text-left"> <ol
<summary>仿</summary> aria-label="仿真工作流程"
<ol aria-label="仿真工作流程" className="space-y-2 text-xs text-text-secondary"> className="mt-6 hidden grid-cols-3 gap-2 border-t border-border-subtle pt-4 sm:grid"
{workflow.map((item) => ( >
<li key={item.label}> {workflow.map((item, index) => (
{item.label} · {item.detail} <li
key={item.label}
className="engineering-card rounded-xl border border-border/70 px-3 py-3 text-left"
>
<div className="flex items-center gap-2">
<span className="technical-value text-[10px] font-semibold text-accent">
0{index + 1}
</span>
<item.icon aria-hidden="true" className="h-3.5 w-3.5 text-text-secondary" />
<span className="text-xs font-semibold text-text-primary">{item.label}</span>
</div>
<span className="mt-1 block text-[10px] leading-4 text-text-tertiary">
{item.detail}
</span>
</li> </li>
))} ))}
</ol> </ol>
</details> <p className="mt-4 flex items-center justify-center gap-1.5 text-[10px] text-text-tertiary">
<p className="mt-3 text-xs text-text-tertiary"></p> <ShieldCheck aria-hidden="true" className="h-3.5 w-3.5 text-success" />
</p>
</div>
</section> </section>
); );
} }
@@ -135,10 +135,14 @@ export function WorkspaceToolsPanel({
keepMounted keepMounted
badge={<ConsoleSectionBadges count={snapshot.actuators.length} />} badge={<ConsoleSectionBadges count={snapshot.actuators.length} />}
> >
{snapshot.actuators.some((actuator) => ['motor', 'position'].includes(actuator.kind)) && (
<p className="mb-2 text-xs text-warning"> MJCF </p>
)}
{snapshot.actuators.length ? ( {snapshot.actuators.length ? (
snapshot.actuators.map((actuator) => ( snapshot.actuators.map((actuator) => (
<ActuatorControl <ActuatorControl
key={actuator.id} key={actuator.id}
showParameterNotice={false}
actuator={actuator} actuator={actuator}
onControl={(value) => onActuator(actuator.id, value)} onControl={(value) => onActuator(actuator.id, value)}
onParameters={(parameters) => onActuatorParameters(actuator.id, parameters)} onParameters={(parameters) => onActuatorParameters(actuator.id, parameters)}
+3 -2
View File
@@ -20,7 +20,8 @@ export function Button({
secondary: 'border-border bg-surface text-text-primary hover:bg-element-hover', secondary: 'border-border bg-surface text-text-primary hover:bg-element-hover',
ghost: ghost:
'border-transparent bg-transparent text-text-secondary hover:bg-element-hover hover:text-text-primary', 'border-transparent bg-transparent text-text-secondary hover:bg-element-hover hover:text-text-primary',
danger: 'border-danger-border bg-danger-soft text-danger hover:bg-danger hover:text-white', danger:
'border-danger-border bg-danger-soft text-danger hover:bg-danger hover:text-[var(--ui-danger-contrast)]',
}; };
const sizes = { const sizes = {
sm: 'h-control gap-1.5 rounded-md px-2 text-xs', sm: 'h-control gap-1.5 rounded-md px-2 text-xs',
@@ -30,7 +31,7 @@ export function Button({
return ( return (
<button <button
type={type} type={type}
className={`inline-flex shrink-0 select-none items-center justify-center border font-medium transition-[color,background-color,border-color,transform,box-shadow] duration-150 ease-out enabled:hover:-translate-y-px enabled:active:translate-y-0 enabled:active:scale-[0.98] focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-accent disabled:cursor-not-allowed disabled:opacity-40 ${variants[variant]} ${sizes[size]} ${className}`.trim()} className={`cyber-action cyber-button-${variant} inline-flex shrink-0 select-none items-center justify-center border font-medium transition-[color,background-color,border-color,transform,box-shadow] duration-150 ease-out enabled:hover:-translate-y-px enabled:active:translate-y-0 enabled:active:scale-[0.98] focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-accent disabled:cursor-not-allowed disabled:opacity-40 ${variants[variant]} ${sizes[size]} ${className}`.trim()}
{...props} {...props}
> >
{icon && ( {icon && (
@@ -25,7 +25,7 @@ export function CollapsibleSection({
type="button" type="button"
aria-expanded={expanded} aria-expanded={expanded}
onClick={() => setOpen((value) => !value)} onClick={() => setOpen((value) => !value)}
className="flex h-9 w-full items-center gap-2 px-3 text-left text-xs font-semibold text-text-secondary transition-colors hover:bg-element-hover focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-inset focus-visible:ring-accent" className="cyber-section-heading flex h-9 w-full items-center gap-2 px-3 text-left text-xs font-semibold text-text-secondary transition-colors hover:bg-element-hover focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-inset focus-visible:ring-accent"
> >
<ChevronRight <ChevronRight
aria-hidden="true" aria-hidden="true"
+1 -1
View File
@@ -118,7 +118,7 @@ export function Dialog({
role="dialog" role="dialog"
aria-modal="true" aria-modal="true"
aria-labelledby={titleId} aria-labelledby={titleId}
className={`relative flex w-full flex-col overflow-hidden border border-border bg-panel shadow-2xl focus:outline-none ${ className={`cyber-dialog relative flex w-full flex-col overflow-hidden border border-border focus:outline-none ${
placement === 'bottom' placement === 'bottom'
? 'max-h-[42vh] max-w-none rounded-t-xl border-x-0 border-b-0' ? 'max-h-[42vh] max-w-none rounded-t-xl border-x-0 border-b-0'
: 'max-h-[80vh] max-w-lg rounded-xl' : 'max-h-[80vh] max-w-lg rounded-xl'
@@ -17,7 +17,7 @@ export function IconButton({
type={type} type={type}
aria-label={props['aria-label'] ?? tooltip} aria-label={props['aria-label'] ?? tooltip}
aria-pressed={active || undefined} aria-pressed={active || undefined}
className={`inline-flex h-hit-compact w-hit-compact shrink-0 items-center justify-center rounded-md border transition-[color,background-color,border-color,transform,box-shadow] duration-150 ease-out enabled:active:scale-95 focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-accent disabled:cursor-not-allowed disabled:opacity-40 ${active ? 'tool-active-glow border-accent/40 bg-accent-soft text-accent' : 'border-transparent bg-transparent text-text-tertiary hover:bg-element-hover hover:text-text-primary'} ${className}`.trim()} className={`cyber-action inline-flex h-hit-compact w-hit-compact shrink-0 items-center justify-center rounded-md border transition-[color,background-color,border-color,transform,box-shadow] duration-150 ease-out enabled:active:scale-95 focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-accent disabled:cursor-not-allowed disabled:opacity-40 ${active ? 'tool-active-glow border-accent/40 bg-accent-soft text-accent' : 'border-transparent bg-transparent text-text-tertiary hover:bg-element-hover hover:text-text-primary'} ${className}`.trim()}
{...props} {...props}
/> />
); );
+1 -1
View File
@@ -75,7 +75,7 @@ export function Popover({
aria-label={label} aria-label={label}
tabIndex={-1} tabIndex={-1}
style={{ ...style, zIndex: layer.zIndex }} style={{ ...style, zIndex: layer.zIndex }}
className="ui-popover overflow-auto rounded-lg text-xs text-text-primary focus:outline-none" className="ui-popover engineering-glass overflow-auto rounded-lg text-xs text-text-primary focus:outline-none"
onKeyDown={(event) => { onKeyDown={(event) => {
if (event.key !== 'Tab' || !layer.isTop()) return; if (event.key !== 'Tab' || !layer.isTop()) return;
const items = Array.from( const items = Array.from(
+2 -2
View File
@@ -46,7 +46,7 @@ export function Tabs<T extends string>({
<div <div
role="tablist" role="tablist"
aria-label={label} aria-label={label}
className="flex h-9 shrink-0 items-end gap-1 border-b border-border bg-surface px-2" className="cyber-tabs flex h-9 shrink-0 items-end gap-1 border-b border-border bg-surface px-2"
> >
{items.map((item) => ( {items.map((item) => (
<button <button
@@ -60,7 +60,7 @@ export function Tabs<T extends string>({
disabled={item.disabled} disabled={item.disabled}
onClick={() => onValueChange(item.value)} onClick={() => onValueChange(item.value)}
onKeyDown={navigate} onKeyDown={navigate}
className={`relative flex h-8 items-center gap-1.5 px-2 text-xs font-medium focus-visible:ring-2 focus-visible:ring-inset focus-visible:ring-accent ${item.value === active.value ? 'text-accent after:absolute after:inset-x-1 after:bottom-0 after:h-0.5 after:rounded-full after:bg-accent' : 'text-text-tertiary hover:text-text-primary'}`} className={`cyber-tab relative flex h-8 items-center gap-1.5 px-2 text-xs font-medium focus-visible:ring-2 focus-visible:ring-inset focus-visible:ring-accent ${item.value === active.value ? 'text-accent after:absolute after:inset-x-1 after:bottom-0 after:h-0.5 after:rounded-full after:bg-accent' : 'text-text-tertiary hover:text-text-primary'}`}
> >
{item.icon} {item.icon}
{item.label} {item.label}
@@ -28,6 +28,23 @@ const actuator: ActuatorInfo = {
}; };
describe('ActuatorControl', () => { describe('ActuatorControl', () => {
it('列表模式不重复参数提示,不限幅输入保持紧凑且可编辑', () => {
const onControl = vi.fn();
render(
<ActuatorControl
actuator={{ ...actuator, ctrlLimited: false }}
showParameterNotice={false}
onControl={onControl}
onParameters={vi.fn()}
/>,
);
expect(screen.queryByText('参数立即作用于模型,可随 MJCF 导出。')).not.toBeInTheDocument();
const input = screen.getByLabelText('控制输入(不限幅)');
expect(input.parentElement).toHaveClass('grid-cols-[minmax(0,1fr)_6rem]');
fireEvent.change(input, { target: { value: '3' } });
fireEvent.blur(input);
expect(onControl).toHaveBeenCalledWith(3);
});
it('显示对应关节和常用力矩单位', () => { it('显示对应关节和常用力矩单位', () => {
render(<ActuatorControl actuator={actuator} onControl={() => {}} onParameters={() => {}} />); render(<ActuatorControl actuator={actuator} onControl={() => {}} onParameters={() => {}} />);
expect(screen.getByText('shoulder_motor')).toBeVisible(); expect(screen.getByText('shoulder_motor')).toBeVisible();
@@ -5,8 +5,10 @@ export function ActuatorControl({
actuator, actuator,
onControl, onControl,
onParameters, onParameters,
showParameterNotice = true,
}: { }: {
actuator: ActuatorInfo; actuator: ActuatorInfo;
showParameterNotice?: boolean;
onControl: (value: number) => void; onControl: (value: number) => void;
onParameters: (parameters: ActuatorParameters) => void; onParameters: (parameters: ActuatorParameters) => void;
}) { }) {
@@ -53,8 +55,8 @@ export function ActuatorControl({
controlLabel = isPosition ? (actuator.jointType === 3 ? '角度' : '位置') : '控制', controlLabel = isPosition ? (actuator.jointType === 3 ? '角度' : '位置') : '控制',
gearSquared = actuator.gear * actuator.gear; gearSquared = actuator.gear * actuator.gear;
return ( return (
<div className="mb-3 border-b border-border pb-3"> <div className="mb-2 border-b border-border pb-2">
<div className="mb-2 flex min-w-0 items-start justify-between gap-2"> <div className="mb-1 flex min-w-0 items-start justify-between gap-2">
<div className="min-w-0"> <div className="min-w-0">
<div className="truncate text-xs font-medium text-text-primary" title={actuator.name}> <div className="truncate text-xs font-medium text-text-primary" title={actuator.name}>
{actuator.name} {actuator.name}
@@ -67,6 +69,7 @@ export function ActuatorControl({
</div> </div>
{actuator.controlCount === 1 ? ( {actuator.controlCount === 1 ? (
<ControlSlider <ControlSlider
compact
label={outputLabel} label={outputLabel}
value={outputValue} value={outputValue}
min={outputMin} min={outputMin}
@@ -84,19 +87,20 @@ export function ActuatorControl({
</p> </p>
)} )}
{actuator.controlCount === 1 && !actuator.ctrlLimited && ( {actuator.controlCount === 1 && !actuator.ctrlLimited && (
<div className="mb-2"> <div className="mb-1">
<ParameterInput <ParameterInput
inline
label={`${controlLabel}输入(不限幅)`} label={`${controlLabel}输入(不限幅)`}
value={actuator.value * targetScale} value={actuator.value * targetScale}
onCommit={(value) => onControl(value / targetScale)} onCommit={(value) => onControl(value / targetScale)}
/> />
</div> </div>
)} )}
{editable && ( {editable && showParameterNotice && (
<p className="mb-2 text-xs text-warning"> MJCF </p> <p className="mb-2 text-xs text-warning"> MJCF </p>
)} )}
{editable ? ( {editable ? (
<details className="group border-t border-border pt-2"> <details className="group pt-1">
<summary className="cursor-pointer select-none text-xs font-medium text-text-secondary hover:text-text-primary"> <summary className="cursor-pointer select-none text-xs font-medium text-text-secondary hover:text-text-primary">
</summary> </summary>
@@ -191,7 +195,9 @@ function ParameterInput({
value, value,
onCommit, onCommit,
disabled = false, disabled = false,
inline = false,
}: { }: {
inline?: boolean;
label: string; label: string;
value: number; value: number;
onCommit: (value: number) => void; onCommit: (value: number) => void;
@@ -200,6 +206,8 @@ function ParameterInput({
return ( return (
<ScrubbableNumberInput <ScrubbableNumberInput
label={label} label={label}
containerClassName={inline ? 'grid grid-cols-[minmax(0,1fr)_6rem] items-center gap-2' : ''}
labelClassName={inline ? '!mb-0' : ''}
value={Number.isFinite(value) ? value : 0} value={Number.isFinite(value) ? value : 0}
step={Math.max(0.001, Math.abs(value) * 0.01)} step={Math.max(0.001, Math.abs(value) * 0.01)}
disabled={disabled} disabled={disabled}
@@ -264,7 +272,9 @@ export function ControlSlider({
limitsIgnored = false, limitsIgnored = false,
limitMin = 0, limitMin = 0,
limitMax = 0, limitMax = 0,
compact = false,
}: { }: {
compact?: boolean;
label: string; label: string;
value: number; value: number;
min: number; min: number;
@@ -281,7 +291,7 @@ export function ControlSlider({
const sane = Number.isFinite(value) ? value : 0, const sane = Number.isFinite(value) ? value : 0,
format = (number: number) => `${number.toFixed(3)}${unit}`; format = (number: number) => `${number.toFixed(3)}${unit}`;
return ( return (
<label className="mb-3 block text-xs"> <label className={`${compact ? 'mb-1' : 'mb-3'} block text-xs`}>
<span className="mb-1 flex justify-between gap-2"> <span className="mb-1 flex justify-between gap-2">
<span className="truncate text-text-secondary">{label}</span> <span className="truncate text-text-secondary">{label}</span>
<output className="technical-value text-text-primary">{format(sane)}</output> <output className="technical-value text-text-primary">{format(sane)}</output>
+203 -41
View File
@@ -29,10 +29,13 @@
--ui-text-primary: #102033; --ui-text-primary: #102033;
--ui-text-secondary: #3a4d62; --ui-text-secondary: #3a4d62;
--ui-text-tertiary: #57687b; --ui-text-tertiary: #57687b;
--ui-accent: #138462; --ui-accent: #087887;
--ui-accent-hover: #0d6d50; --ui-accent-hover: #06616e;
--ui-accent-soft: #ddf5ec; --ui-accent-soft: #def3f6;
--ui-violet: #7450b5;
--ui-glow: rgb(8 120 135 / 12%);
--ui-danger: #c53b45; --ui-danger: #c53b45;
--ui-danger-contrast: #fff;
--ui-danger-soft: #fff0f1; --ui-danger-soft: #fff0f1;
--ui-danger-border: #f1b9bd; --ui-danger-border: #f1b9bd;
--ui-warning: #a76612; --ui-warning: #a76612;
@@ -49,25 +52,28 @@
color-scheme: light; color-scheme: light;
} }
.theme-dark { .theme-dark {
--ui-accent-contrast: #071c14; --ui-accent-contrast: #04171e;
--ui-bg: #070b12; --ui-bg: #060914;
--ui-panel: #0d141f; --ui-panel: #0b1224;
--ui-panel-muted: #101a28; --ui-panel-muted: #101a2d;
--ui-surface: #121d2a; --ui-surface: #121e33;
--ui-surface-elevated: #182536; --ui-surface-elevated: #19283e;
--ui-input: #090f18; --ui-input: #080f1e;
--ui-hover: #172638; --ui-hover: #1a3047;
--ui-active: #1d3147; --ui-active: #203d54;
--ui-border: #1e2d3e; --ui-border: #2a4058;
--ui-border-subtle: #162333; --ui-border-subtle: #1b2b42;
--ui-border-strong: #668099; --ui-border-strong: #70869e;
--ui-text-primary: #eaf2fb; --ui-text-primary: #edf6ff;
--ui-text-secondary: #b7c5d5; --ui-text-secondary: #becfe2;
--ui-text-tertiary: #94a6ba; --ui-text-tertiary: #9baec6;
--ui-accent: #38d39f; --ui-accent: #36e4f2;
--ui-accent-hover: #2db789; --ui-accent-hover: #7eeef7;
--ui-accent-soft: #123b31; --ui-accent-soft: #10343f;
--ui-violet: #a78bfa;
--ui-glow: rgb(54 228 242 / 16%);
--ui-danger: #ff7282; --ui-danger: #ff7282;
--ui-danger-contrast: #260b12;
--ui-danger-soft: #391b24; --ui-danger-soft: #391b24;
--ui-danger-border: #68303b; --ui-danger-border: #68303b;
--ui-warning: #ffad42; --ui-warning: #ffad42;
@@ -78,7 +84,7 @@
--ui-success-border: #275d50; --ui-success-border: #275d50;
--ui-scrollbar: #34475c; --ui-scrollbar: #34475c;
--ui-scrollbar-hover: #536b84; --ui-scrollbar-hover: #536b84;
--ui-glass: rgb(8 15 24 / 82%); --ui-glass: rgb(11 18 36 / 88%);
--ui-glass-highlight: rgb(255 255 255 / 7%); --ui-glass-highlight: rgb(255 255 255 / 7%);
--ui-shadow: rgb(0 0 0 / 48%); --ui-shadow: rgb(0 0 0 / 48%);
color-scheme: dark; color-scheme: dark;
@@ -140,13 +146,30 @@
font-family: 'JetBrains Mono', 'SFMono-Regular', Consolas, 'Liberation Mono', monospace; font-family: 'JetBrains Mono', 'SFMono-Regular', Consolas, 'Liberation Mono', monospace;
font-variant-numeric: tabular-nums; font-variant-numeric: tabular-nums;
} }
.cyber-workspace {
background:
radial-gradient(ellipse at 8% 0%, var(--ui-glow), transparent 45%),
radial-gradient(
ellipse at 95% 100%,
color-mix(in srgb, var(--ui-violet) 7%, transparent),
transparent 45%
),
var(--ui-bg);
}
.workbench-header { .workbench-header {
background: linear-gradient( background:
90deg, linear-gradient(100deg, var(--ui-glass-highlight), transparent 35%), var(--ui-panel);
color-mix(in srgb, var(--ui-panel) 94%, var(--ui-surface)), border-bottom-color: color-mix(in srgb, var(--ui-accent) 24%, var(--ui-border));
var(--ui-panel) 42%, box-shadow: 0 1px 18px var(--ui-glow);
color-mix(in srgb, var(--ui-panel) 88%, var(--ui-accent) 12%) }
); /* 长列表侧栏采用静态玻璃质感,避免滚动时持续采样大面积背景。 */
.cyber-sidebar {
background:
linear-gradient(180deg, var(--ui-glass-highlight), transparent 180px), var(--ui-panel);
}
.cyber-sidebar > h2 {
background: linear-gradient(90deg, var(--ui-glow), transparent 85%);
box-shadow: inset 2px 0 var(--ui-accent);
} }
.viewport-shell { .viewport-shell {
isolation: isolate; isolation: isolate;
@@ -166,15 +189,33 @@
pointer-events: none; pointer-events: none;
background: linear-gradient(180deg, rgb(255 255 255 / 2%), transparent 22%, rgb(0 0 0 / 7%)); background: linear-gradient(180deg, rgb(255 255 255 / 2%), transparent 22%, rgb(0 0 0 / 7%));
} }
.engineering-glass { .engineering-glass,
border-color: color-mix(in srgb, var(--ui-border-strong) 72%, transparent); .workspace-welcome,
.cyber-dialog,
.source-editor-window {
border-color: color-mix(in srgb, var(--ui-accent) 22%, var(--ui-border));
background: background:
linear-gradient(135deg, var(--ui-glass-highlight), transparent 42%), var(--ui-glass); linear-gradient(135deg, var(--ui-glass-highlight), transparent 42%), var(--ui-panel);
box-shadow: box-shadow:
0 16px 44px var(--ui-shadow), 0 16px 44px var(--ui-shadow),
inset 0 1px 0 color-mix(in srgb, white 10%, transparent), 0 0 24px color-mix(in srgb, var(--ui-glow) 45%, transparent),
inset 0 -1px 0 color-mix(in srgb, black 10%, transparent); inset 0 1px 0 var(--ui-glass-highlight);
backdrop-filter: blur(16px) saturate(135%); }
@supports (backdrop-filter: blur(1px)) {
.engineering-glass,
.workspace-welcome,
.cyber-dialog,
.source-editor-window,
.workbench-header {
background-color: var(--ui-glass);
background-image: linear-gradient(135deg, var(--ui-glass-highlight), transparent 42%);
backdrop-filter: blur(12px) saturate(120%);
}
/* 内层表面不重复采样背景,代码和图表区域维持实底。 */
.cyber-sidebar .engineering-glass,
.cyber-dialog .engineering-glass {
backdrop-filter: none;
}
} }
.engineering-card { .engineering-card {
border-color: color-mix(in srgb, var(--ui-border) 88%, transparent); border-color: color-mix(in srgb, var(--ui-border) 88%, transparent);
@@ -183,10 +224,14 @@
color-mix(in srgb, var(--ui-surface) 88%, transparent), color-mix(in srgb, var(--ui-surface) 88%, transparent),
color-mix(in srgb, var(--ui-panel) 72%, transparent) color-mix(in srgb, var(--ui-panel) 72%, transparent)
); );
box-shadow: inset 0 1px 0 color-mix(in srgb, white 4%, transparent); box-shadow:
inset 0 1px 0 var(--ui-glass-highlight),
0 4px 16px color-mix(in srgb, var(--ui-shadow) 25%, transparent);
} }
.tool-active-glow { .tool-active-glow {
box-shadow: 0 0 14px color-mix(in srgb, var(--ui-accent) 14%, transparent); box-shadow:
inset 0 0 12px var(--ui-glow),
0 0 12px var(--ui-glow);
} }
.engineering-badge { .engineering-badge {
background-image: linear-gradient(180deg, rgb(255 255 255 / 5%), transparent); background-image: linear-gradient(180deg, rgb(255 255 255 / 5%), transparent);
@@ -201,6 +246,74 @@
.draft-dirty-dot { .draft-dirty-dot {
animation: draft-dirty-pulse 1.8s ease-in-out infinite; animation: draft-dirty-pulse 1.8s ease-in-out infinite;
} }
.cyber-action {
transition-property: color, background-color, border-color, transform, box-shadow;
}
.cyber-button-primary {
background-image: linear-gradient(125deg, rgb(0 0 0 / 6%), transparent 65%);
box-shadow:
0 2px 12px var(--ui-glow),
inset 0 1px 0 rgb(255 255 255 / 20%);
}
.cyber-action:focus-visible,
.cyber-action:focus-within {
box-shadow: 0 0 0 3px var(--ui-glow);
}
@media (hover: hover) {
.cyber-action:not(:disabled, [aria-disabled='true']):hover {
border-color: var(--ui-accent);
box-shadow:
0 0 16px var(--ui-glow),
inset 0 1px 0 var(--ui-glass-highlight);
}
.cyber-button-danger:not(:disabled):hover {
border-color: var(--ui-danger);
box-shadow: 0 0 14px color-mix(in srgb, var(--ui-danger) 16%, transparent);
}
.engineering-card:has(> button):hover {
border-color: color-mix(in srgb, var(--ui-accent) 45%, var(--ui-border));
}
}
.cyber-action:is(:disabled, [aria-disabled='true']) {
box-shadow: none;
transform: none;
}
.cyber-tabs {
background-image: linear-gradient(90deg, var(--ui-glow), transparent 60%);
}
.cyber-tab {
border-radius: 6px 6px 0 0;
transition:
background-color var(--ui-duration),
color var(--ui-duration);
}
.cyber-tab[aria-selected='true'] {
background: linear-gradient(0deg, var(--ui-glow), transparent);
}
.cyber-tab[aria-selected='true']::after {
box-shadow: 0 0 8px var(--ui-glow);
}
.cyber-section-heading[aria-expanded='true'] {
background-image: linear-gradient(90deg, var(--ui-glow), transparent 85%);
color: var(--ui-text-primary);
}
.hud-signal {
position: relative;
width: 5px;
height: 5px;
flex: none;
border-radius: 50%;
background: currentColor;
}
.hud-signal[data-running='true']::after {
content: '';
position: absolute;
inset: -3px;
border: 1px solid currentColor;
border-radius: inherit;
pointer-events: none;
animation: hud-breathe 3.6s ease-in-out infinite;
}
.domain-details { .domain-details {
@apply min-w-0 border-t border-border-subtle py-2 text-xs text-text-secondary; @apply min-w-0 border-t border-border-subtle py-2 text-xs text-text-secondary;
} }
@@ -250,9 +363,20 @@
} }
.workspace-welcome { .workspace-welcome {
animation: workspace-enter 360ms cubic-bezier(0.22, 1, 0.36, 1) both; animation: workspace-enter 360ms cubic-bezier(0.22, 1, 0.36, 1) both;
box-shadow: }
0 28px 80px rgb(2 8 23 / 28%), .workspace-welcome::after {
inset 0 1px 0 rgb(255 255 255 / 5%); content: '';
position: absolute;
inset: 0 15% auto;
height: 1px;
pointer-events: none;
background: linear-gradient(
90deg,
transparent,
var(--ui-accent),
var(--ui-violet),
transparent
);
} }
.welcome-glow { .welcome-glow {
position: absolute; position: absolute;
@@ -267,6 +391,17 @@
transform: translateX(-50%); transform: translateX(-50%);
} }
} }
@keyframes hud-breathe {
0%,
100% {
opacity: 0.2;
transform: scale(0.8);
}
50% {
opacity: 0.65;
transform: scale(1.15);
}
}
@keyframes map-tool-enter { @keyframes map-tool-enter {
from { from {
opacity: 0; opacity: 0;
@@ -321,12 +456,36 @@
*::before, *::before,
*::after { *::after {
scroll-behavior: auto !important; scroll-behavior: auto !important;
animation-duration: 0.01ms !important; animation: none !important;
animation-iteration-count: 1 !important;
transition-duration: 0.01ms !important; transition-duration: 0.01ms !important;
} }
} }
@media (forced-colors: active) {
.engineering-glass,
.engineering-card,
.workspace-welcome,
.cyber-dialog,
.cyber-action,
.source-editor-window {
border: 1px solid CanvasText;
box-shadow: none;
backdrop-filter: none;
}
.cyber-tab[aria-selected='true'] {
outline: 1px solid Highlight;
outline-offset: -2px;
}
.hud-signal {
background: CanvasText;
}
.welcome-glow,
.workspace-welcome::after,
.hud-signal::after {
display: none;
}
}
/* 视口尺寸而非窗口尺寸决定浮层避让,双侧栏打开时仍保留完整操作。 */ /* 视口尺寸而非窗口尺寸决定浮层避让,双侧栏打开时仍保留完整操作。 */
.viewport-shell { .viewport-shell {
container-type: inline-size; container-type: inline-size;
@@ -390,6 +549,9 @@
border: 1px solid var(--ui-border-strong); border: 1px solid var(--ui-border-strong);
border-radius: 12px; border-radius: 12px;
background: var(--ui-panel); background: var(--ui-panel);
box-shadow:
0 6px 24px var(--ui-shadow),
inset 0 1px 0 var(--ui-glass-highlight);
} }
.sensor-camera-frame { .sensor-camera-frame {
position: relative; position: relative;
@@ -270,7 +270,7 @@ describe('LocalTrainingPanel 自定义任务', () => {
const fetchMock = customServer(); const fetchMock = customServer();
render(<LocalTrainingPanel onPolicyReady={vi.fn()} />); render(<LocalTrainingPanel onPolicyReady={vi.fn()} />);
await connectCustom(); await connectCustom();
fireEvent.click(screen.getByRole('button', { name: '检查场景地图' })); fireEvent.click(screen.getByRole('button', { name: '同步场景地图' }));
expect(screen.getByRole('alert')).toHaveTextContent('没有已应用'); expect(screen.getByRole('alert')).toHaveTextContent('没有已应用');
fireEvent.change(screen.getByLabelText('训练地形'), { target: { value: 'plane' } }); fireEvent.change(screen.getByLabelText('训练地形'), { target: { value: 'plane' } });
fireEvent.change(screen.getByLabelText('地图尺寸 m'), { target: { value: '100' } }); fireEvent.change(screen.getByLabelText('地图尺寸 m'), { target: { value: '100' } });
@@ -347,7 +347,7 @@ describe('LocalTrainingPanel 配套策略交接', () => {
expect(screen.getByLabelText('训练地形')).toHaveValue('custom_boxes'); expect(screen.getByLabelText('训练地形')).toHaveValue('custom_boxes');
expect(screen.queryByLabelText('出生 X')).not.toBeInTheDocument(); expect(screen.queryByLabelText('出生 X')).not.toBeInTheDocument();
expect(screen.queryByLabelText('目标 Y')).not.toBeInTheDocument(); expect(screen.queryByLabelText('目标 Y')).not.toBeInTheDocument();
expect(screen.getByRole('button', { name: '检查场景地图' })).toHaveAttribute( expect(screen.getByRole('button', { name: '同步场景地图' })).toHaveAttribute(
'title', 'title',
expect.stringContaining('旋转障碍会膨胀'), expect.stringContaining('旋转障碍会膨胀'),
); );
@@ -420,13 +420,13 @@ it('custom_boxes拒绝未应用草稿,且训练时自动重新编译最新地
/>, />,
); );
await connectCustom(); await connectCustom();
fireEvent.click(screen.getByRole('button', { name: '检查场景地图' })); fireEvent.click(screen.getByRole('button', { name: '同步场景地图' }));
expect(screen.getByRole('alert')).toHaveTextContent('草稿'); expect(screen.getByRole('alert')).toHaveTextContent('草稿');
expect(compileScene).not.toHaveBeenCalled(); expect(compileScene).not.toHaveBeenCalled();
rerender( rerender(
<LocalTrainingPanel onPolicyReady={vi.fn()} sceneMaps={assets} compileScene={compileScene} />, <LocalTrainingPanel onPolicyReady={vi.fn()} sceneMaps={assets} compileScene={compileScene} />,
); );
fireEvent.click(screen.getByRole('button', { name: '检查场景地图' })); fireEvent.click(screen.getByRole('button', { name: '同步场景地图' }));
expect(screen.queryByLabelText('出生 X')).not.toBeInTheDocument(); expect(screen.queryByLabelText('出生 X')).not.toBeInTheDocument();
expect(screen.queryByLabelText('目标 X')).not.toBeInTheDocument(); expect(screen.queryByLabelText('目标 X')).not.toBeInTheDocument();
current = { ...current, boxes: [current.boxes[0], { ...current.boxes[1], pos: [1, 3, 0.5] }] }; current = { ...current, boxes: [current.boxes[0], { ...current.boxes[1], pos: [1, 3, 0.5] }] };
@@ -607,7 +607,7 @@ export function LocalTrainingPanel({
title="从已应用实例编译并校验世界 AABB;旋转障碍会膨胀为轴对齐包围盒" title="从已应用实例编译并校验世界 AABB;旋转障碍会膨胀为轴对齐包围盒"
onClick={syncMap} onClick={syncMap}
> >
</Button> </Button>
{terrainPreset === 'custom_boxes' && customTerrainBoxes && !sceneDirty && ( {terrainPreset === 'custom_boxes' && customTerrainBoxes && !sceneDirty && (
<p role="status"> <p role="status">
@@ -245,7 +245,7 @@ function MultiTrialPlot({
}; };
return ( return (
<section className="overflow-hidden rounded-xl border border-border bg-app/80 shadow-[inset_0_1px_0_rgb(255_255_255/0.025)]"> <section className="engineering-card overflow-hidden rounded-xl border border-border">
<header className="flex min-h-10 flex-wrap items-center justify-between gap-2 border-b border-border px-3 py-2"> <header className="flex min-h-10 flex-wrap items-center justify-between gap-2 border-b border-border px-3 py-2">
<div className="min-w-0"> <div className="min-w-0">
<p className="flex items-center gap-1.5 break-all text-xs font-semibold"> <p className="flex items-center gap-1.5 break-all text-xs font-semibold">
+5 -5
View File
@@ -77,9 +77,9 @@ export function TuningApp() {
return ( return (
<div <div
className={`theme-${theme} flex h-full min-h-0 flex-col overflow-x-hidden overflow-y-auto bg-app text-text-primary`} className={`theme-${theme} cyber-workspace flex h-full min-h-0 flex-col overflow-x-hidden overflow-y-auto bg-app text-text-primary`}
> >
<header className="flex min-h-14 shrink-0 flex-wrap items-center justify-between gap-3 border-b border-border bg-surface px-5 py-2.5"> <header className="workbench-header flex min-h-14 shrink-0 flex-wrap items-center justify-between gap-3 border-b border-border px-5 py-2.5">
<div> <div>
<h1 className="flex items-center gap-2 text-base font-semibold"> <h1 className="flex items-center gap-2 text-base font-semibold">
<span className="grid h-7 w-7 place-items-center rounded-lg border border-success-border bg-accent-soft"> <span className="grid h-7 w-7 place-items-center rounded-lg border border-success-border bg-accent-soft">
@@ -120,7 +120,7 @@ export function TuningApp() {
</header> </header>
{!sessionId && ( {!sessionId && (
<section className="grid shrink-0 gap-3 border-b border-border bg-panel/70 p-3 lg:grid-cols-[1fr_1fr_auto]"> <section className="engineering-card grid shrink-0 gap-3 border-b border-border p-3 lg:grid-cols-[1fr_1fr_auto]">
<Field label="训练服务地址"> <Field label="训练服务地址">
<input <input
aria-label="训练服务地址" aria-label="训练服务地址"
@@ -277,7 +277,7 @@ function NewSessionView({
return ( return (
<main className="flex-1 p-4"> <main className="flex-1 p-4">
<div className="mx-auto grid max-w-6xl items-start gap-4 lg:grid-cols-[minmax(0,2fr)_minmax(0,1fr)]"> <div className="mx-auto grid max-w-6xl items-start gap-4 lg:grid-cols-[minmax(0,2fr)_minmax(0,1fr)]">
<section className="border-b border-border bg-surface p-4"> <section className="engineering-card rounded-xl border border-border p-4">
<div className="flex flex-wrap items-start justify-between gap-2"> <div className="flex flex-wrap items-start justify-between gap-2">
<div> <div>
<h2 className="text-sm font-semibold"> {taskId} Session</h2> <h2 className="text-sm font-semibold"> {taskId} Session</h2>
@@ -456,7 +456,7 @@ function NewSessionView({
</Button> </Button>
</section> </section>
<section className="border-b border-border bg-surface p-4"> <section className="engineering-card rounded-xl border border-border p-4">
<div className="flex items-center justify-between"> <div className="flex items-center justify-between">
<h2 className="text-xs font-semibold"> Sessions</h2> <h2 className="text-xs font-semibold"> Sessions</h2>
<Badge>{sessions.length}</Badge> <Badge>{sessions.length}</Badge>
+14 -41
View File
@@ -1,5 +1,3 @@
import { useState } from 'react';
import { Button } from '../components/ui';
import { PretrainedIdentity } from '../training/PretrainedSourceSelect'; import { PretrainedIdentity } from '../training/PretrainedSourceSelect';
import { useTuningStore } from './tuningStore'; import { useTuningStore } from './tuningStore';
import { AgentDecisionTimeline } from './AgentDecisionTimeline'; import { AgentDecisionTimeline } from './AgentDecisionTimeline';
@@ -12,59 +10,34 @@ import { useTuningPolling } from './useTuningPolling';
export function TuningConsole() { export function TuningConsole() {
useTuningPolling(); useTuningPolling();
const pretrained = useTuningStore((state) => state.sessionConfig?.pretrained); const pretrained = useTuningStore((state) => state.sessionConfig?.pretrained);
const pending = useTuningStore(
(state) =>
state.proposalIds.filter((id) => state.proposalsById[id]?.state === 'pending').length,
);
const [side, setSide] = useState<'sessions' | 'decisions' | null>(null);
return ( return (
<div className="min-w-0 flex-1"> <div className="flex min-h-0 min-w-0 flex-1 flex-col">
<TuningControlToolbar /> <TuningControlToolbar />
<div className="px-3"> <div className="px-3">
<PretrainedIdentity source={pretrained} /> <PretrainedIdentity source={pretrained} />
</div> </div>
<nav <div className="grid min-w-0 grid-cols-1 xl:min-h-0 xl:flex-1 xl:grid-cols-[286px_minmax(520px,1fr)_390px]">
aria-label="调参详情"
className="flex flex-wrap items-center gap-2 border-b border-border p-3"
>
<Button
aria-expanded={side === 'sessions'}
aria-controls="tuning-sessions"
onClick={() => setSide(side === 'sessions' ? null : 'sessions')}
>
Trial
</Button>
<Button
variant={pending ? 'primary' : 'secondary'}
aria-expanded={side === 'decisions'}
aria-controls="tuning-decisions"
onClick={() => setSide(side === 'decisions' ? null : 'decisions')}
>
{pending ? ` · 待审批 ${pending}` : ''}
</Button>
<span className="text-xs text-text-tertiary">
{pending ? '审批后将调度候选训练;请先审查参数变化。' : '指标与排行随当前会话更新'}
</span>
</nav>
<div <div
className={`grid min-w-0 ${side ? 'lg:grid-cols-[minmax(0,1fr)_minmax(300px,380px)]' : ''}`} id="tuning-sessions"
role="region"
aria-label="会话与 Trial"
className="cyber-sidebar min-h-0 min-w-0 border-b border-border xl:border-b-0 xl:border-r"
> >
<main className="min-w-0 bg-app p-3"> <TuningSessionRail />
</div>
<main aria-label="指标与排行" className="min-h-0 min-w-0 p-3 panel-scroll xl:overflow-auto">
<div className="mx-auto max-w-[1500px] space-y-4"> <div className="mx-auto max-w-[1500px] space-y-4">
<MetricsComparisonBoard /> <MetricsComparisonBoard />
<TuningLeaderboard /> <TuningLeaderboard />
</div> </div>
</main> </main>
<div <aside
className={`min-w-0 border-l border-border bg-panel ${side ? 'max-lg:row-start-1' : 'hidden'}`} id="tuning-decisions"
aria-label="决策与审批"
className="cyber-sidebar min-h-0 min-w-0 border-t border-border p-3 panel-scroll xl:overflow-auto xl:border-l xl:border-t-0"
> >
<div id="tuning-sessions" hidden={side !== 'sessions'}>
<TuningSessionRail />
</div>
<div id="tuning-decisions" hidden={side !== 'decisions'} className="p-3">
<AgentDecisionTimeline /> <AgentDecisionTimeline />
</div> </aside>
</div>
</div> </div>
</div> </div>
); );
@@ -37,7 +37,7 @@ function AshaPromotionTree() {
}); });
void entitiesRevision; void entitiesRevision;
return ( return (
<section className="rounded-lg border border-border bg-app/70 p-2.5"> <section className="engineering-card rounded-lg border border-border p-2.5">
<div className="mb-2 flex items-center justify-between"> <div className="mb-2 flex items-center justify-between">
<h2 className="flex items-center gap-1.5 text-xs font-semibold uppercase tracking-wider text-text-secondary"> <h2 className="flex items-center gap-1.5 text-xs font-semibold uppercase tracking-wider text-text-secondary">
<GitBranch className="h-3.5 w-3.5 text-accent" /> ASHA <GitBranch className="h-3.5 w-3.5 text-accent" /> ASHA
@@ -164,7 +164,7 @@ export function TuningSessionRail() {
void entitiesRevision; void entitiesRevision;
return ( return (
<aside className="flex min-w-0 flex-col bg-panel"> <aside className="flex min-h-0 min-w-0 flex-col xl:h-full">
<div className="shrink-0 border-b border-border p-3"> <div className="shrink-0 border-b border-border p-3">
<div className="flex items-start justify-between gap-2"> <div className="flex items-start justify-between gap-2">
<div className="min-w-0"> <div className="min-w-0">