From d8d7bc3401a20bdfa09804bd5d7c9e06910f3eb0 Mon Sep 17 00:00:00 2001 From: chen-985211 <2301091735@qq.com> Date: Fri, 10 Jul 2026 19:38:48 +0800 Subject: [PATCH 1/3] feat: refine console and control center UI --- package.json | 3 +- scripts/check-ui-styles.mjs | 238 ++++++++++ src/app/renderer/components/CoveSelect.tsx | 27 +- .../renderer/components/ui/Button.spec.tsx | 47 ++ src/app/renderer/components/ui/Button.tsx | 46 ++ .../renderer/components/ui/Dialog.spec.tsx | 67 +++ src/app/renderer/components/ui/Dialog.tsx | 171 +++++++ .../components/ui/DismissableLayer.tsx | 145 ++++++ .../components/ui/IconButton.spec.tsx | 30 ++ src/app/renderer/components/ui/IconButton.tsx | 46 ++ .../renderer/components/ui/Popover.spec.tsx | 244 ++++++++++ src/app/renderer/components/ui/Popover.tsx | 206 +++++++++ .../components/ui/SettingRow.spec.tsx | 42 ++ src/app/renderer/components/ui/SettingRow.tsx | 55 +++ .../renderer/components/ui/Toggle.spec.tsx | 48 ++ src/app/renderer/components/ui/Toggle.tsx | 59 +++ .../ui/anchoredSurfacePlacement.spec.ts | 88 ++++ .../components/ui/anchoredSurfacePlacement.ts | 121 +++++ src/app/renderer/components/ui/classNames.ts | 5 + .../components/ui/overlayLayerRegistry.ts | 58 +++ src/app/renderer/i18n/locales/en.ts | 3 + src/app/renderer/i18n/locales/zh-CN.ts | 3 + src/app/renderer/shell/AppShell.tsx | 12 +- .../renderer/shell/components/AppHeader.tsx | 3 + .../shell/components/AppShellOverlays.tsx | 12 +- .../shell/components/ControlCenter.tsx | 279 +++++------- .../RemoteDirectoryPickerWindow.tsx | 3 +- src/app/renderer/styles.css | 3 + src/app/renderer/styles/app-header.css | 102 +++-- src/app/renderer/styles/app-message.css | 30 +- src/app/renderer/styles/app-notifications.css | 52 +-- src/app/renderer/styles/base.css | 221 ++++----- src/app/renderer/styles/command-center.css | 51 ++- src/app/renderer/styles/control-center.css | 383 ++++++++++------ src/app/renderer/styles/cove-fields.css | 52 +-- src/app/renderer/styles/cove-window.css | 101 ++--- src/app/renderer/styles/document-node.css | 12 +- src/app/renderer/styles/image-node.css | 36 +- src/app/renderer/styles/issue-report.css | 8 +- src/app/renderer/styles/node-chrome.css | 74 +++ src/app/renderer/styles/note-node.css | 8 +- src/app/renderer/styles/remote-ui.css | 34 +- src/app/renderer/styles/role-node.css | 17 +- .../styles/settings-panel.buttons.css | 32 +- src/app/renderer/styles/settings-panel.css | 128 +++--- .../renderer/styles/settings-panel.lists.css | 2 +- .../renderer/styles/settings-panel.search.css | 2 +- .../styles/settings-panel.sidebar.css | 27 +- .../styles/task-node.agent-sessions.css | 6 +- src/app/renderer/styles/task-node.css | 34 +- src/app/renderer/styles/terminal-node.css | 56 ++- .../renderer/styles/themes/ember-light.css | 5 +- src/app/renderer/styles/themes/ember.css | 5 +- src/app/renderer/styles/ui-controls.css | 316 +++++++++++++ src/app/renderer/styles/ui-overlays.css | 76 ++++ src/app/renderer/styles/website-node.css | 26 +- src/app/renderer/styles/whats-new.css | 6 +- .../renderer/styles/workspace-agent-item.css | 27 +- src/app/renderer/styles/workspace-canvas.css | 121 ++--- .../styles/workspace-context-menu.css | 8 +- .../renderer/styles/workspace-overlays.css | 27 +- .../styles/workspace-overlays.spaces.css | 36 +- .../workspace-overlays.spaces.explorer.css | 4 +- ...orkspace-overlays.spaces.explorer.tree.css | 2 +- .../workspace-overlays.worktree.archive.css | 8 +- .../styles/workspace-overlays.worktree.css | 100 ++--- .../workspace-overlays.worktree.light.css | 104 ----- src/app/renderer/styles/workspace-pr-chip.css | 18 +- .../styles/workspace-search-panel.css | 46 +- src/app/renderer/styles/workspace-sidebar.css | 44 +- .../renderer/styles/workspace-sidebar.dnd.css | 4 +- .../styles/workspace-sidebar.fade.css | 24 +- .../styles/workspace-sidebar.rail.css | 12 +- .../styles/workspace-sidebar.surface.css | 2 +- .../presentation/renderer/SettingsPanel.tsx | 420 +++++++++--------- .../settingsPanel/SettingsPanelSidebar.tsx | 5 +- .../TaskPromptTemplatesMenu.tsx | 14 +- .../windows/TaskCreatorWindow.tsx | 1 + .../windows/TaskEditorWindow.tsx | 1 + tests/e2e/control-center.spec.ts | 202 ++++++++- tests/e2e/settings.spec.ts | 27 ++ tests/unit/scripts/ui-style-check.spec.ts | 123 +++++ 82 files changed, 3966 insertions(+), 1380 deletions(-) create mode 100644 scripts/check-ui-styles.mjs create mode 100644 src/app/renderer/components/ui/Button.spec.tsx create mode 100644 src/app/renderer/components/ui/Button.tsx create mode 100644 src/app/renderer/components/ui/Dialog.spec.tsx create mode 100644 src/app/renderer/components/ui/Dialog.tsx create mode 100644 src/app/renderer/components/ui/DismissableLayer.tsx create mode 100644 src/app/renderer/components/ui/IconButton.spec.tsx create mode 100644 src/app/renderer/components/ui/IconButton.tsx create mode 100644 src/app/renderer/components/ui/Popover.spec.tsx create mode 100644 src/app/renderer/components/ui/Popover.tsx create mode 100644 src/app/renderer/components/ui/SettingRow.spec.tsx create mode 100644 src/app/renderer/components/ui/SettingRow.tsx create mode 100644 src/app/renderer/components/ui/Toggle.spec.tsx create mode 100644 src/app/renderer/components/ui/Toggle.tsx create mode 100644 src/app/renderer/components/ui/anchoredSurfacePlacement.spec.ts create mode 100644 src/app/renderer/components/ui/anchoredSurfacePlacement.ts create mode 100644 src/app/renderer/components/ui/classNames.ts create mode 100644 src/app/renderer/components/ui/overlayLayerRegistry.ts create mode 100644 src/app/renderer/styles/node-chrome.css create mode 100644 src/app/renderer/styles/ui-controls.css create mode 100644 src/app/renderer/styles/ui-overlays.css delete mode 100644 src/app/renderer/styles/workspace-overlays.worktree.light.css create mode 100644 tests/unit/scripts/ui-style-check.spec.ts diff --git a/package.json b/package.json index 9c7988474..ad64fb465 100644 --- a/package.json +++ b/package.json @@ -102,13 +102,14 @@ "secrets:scan": "secretlint \"**/*\"", "naming-check:staged": "node scripts/check-naming-staged.mjs", "line-check:staged": "node scripts/check-max-lines.mjs", + "ui:style-check": "node scripts/check-ui-styles.mjs", "test:staged": "node scripts/run-vitest-related-staged.mjs", "test:e2e:pre-commit": "node scripts/run-precommit-e2e.mjs", "release:patch": "node scripts/prepare-release.mjs patch", "release:minor": "node scripts/prepare-release.mjs minor", "release:major": "node scripts/prepare-release.mjs major", "release:version": "node scripts/prepare-release.mjs", - "pre-commit": "pnpm line-check:staged && pnpm secret-check:staged && pnpm naming-check:staged && pnpm lint:fix && pnpm format-check:staged && pnpm check && pnpm test:staged && pnpm test:e2e:pre-commit", + "pre-commit": "pnpm line-check:staged && pnpm secret-check:staged && pnpm naming-check:staged && pnpm ui:style-check && pnpm lint:fix && pnpm format-check:staged && pnpm check && pnpm test:staged && pnpm test:e2e:pre-commit", "prepare": "husky" }, "dependencies": { diff --git a/scripts/check-ui-styles.mjs b/scripts/check-ui-styles.mjs new file mode 100644 index 000000000..e0d9ddb7f --- /dev/null +++ b/scripts/check-ui-styles.mjs @@ -0,0 +1,238 @@ +#!/usr/bin/env node + +import { readFileSync, readdirSync } from 'node:fs' +import { dirname, relative, resolve } from 'node:path' +import { fileURLToPath, pathToFileURL } from 'node:url' + +const SCRIPT_DIR = dirname(fileURLToPath(import.meta.url)) +const DEFAULT_REPO_ROOT = resolve(SCRIPT_DIR, '..') +const UI_STYLE_ROOT = 'src/app/renderer' +const BASE_TOKEN_FILE = 'src/app/renderer/styles/base.css' +const THEME_DIRECTORY = 'src/app/renderer/styles/themes/' + +const TOKEN_DECLARATION_PATTERN = /(--cove-[a-z0-9_-]+)\s*:/gi +const TOKEN_USAGE_PATTERN = /var\(\s*(--cove-[a-z0-9_-]+)/gi +const RAW_COLOR_PATTERN = + /(?:#[0-9a-f]{8}|#[0-9a-f]{6}|#[0-9a-f]{4}|#[0-9a-f]{3})(?![0-9a-f])|rgba?\s*\([^)]*\)/gi +const THEME_BRANCH_PATTERN = /\[\s*data-cove-theme(?:-id)?(?:\s*[~|^$*]?=|\s*\])/gi + +function colorSet(colors) { + return new Set(colors.map(canonicalizeRawColor)) +} + +// These colors mirror the third-party OpenCode terminal palette. They are part +// of the terminal-provider presentation protocol rather than OpenCove chrome. +const OPENCODE_TERMINAL_COLORS = colorSet([ + 'rgba(107, 138, 236, 0.65)', + 'rgba(104, 137, 226, 0.45)', + 'rgba(0, 0, 0, 0.45)', + 'rgba(153, 223, 255, 0.88)', + 'rgba(168, 233, 255, 0.42)', + 'rgba(0, 0, 0, 0.52)', + 'rgba(7, 12, 24, 0.9)', + 'rgba(18, 28, 50, 0.96)', + '#0a0f1d', + '#d6e4ff', + 'rgba(94, 156, 255, 0.35)', + 'rgba(255, 255, 255, 0.92)', + 'rgba(255, 255, 255, 0.6)', + 'rgba(255, 255, 255, 0.4)', + 'rgba(255, 255, 255, 0.12)', + 'rgba(255, 255, 255, 0.08)', + 'rgba(255, 255, 255, 0.04)', +]) + +// Provider icon accents identify external agent brands. Only these audited +// constants are exempt; other colors in the same component still need tokens. +const AGENT_PROVIDER_BRAND_COLORS = colorSet([ + 'rgba(148, 163, 184, 0.92)', + 'rgba(217, 119, 87, 0.96)', + 'rgba(125, 211, 252, 0.96)', + 'rgba(96, 165, 250, 0.96)', + 'rgba(196, 181, 253, 0.96)', +]) + +export const DEFAULT_RAW_COLOR_ALLOWLIST = new Map([ + ['src/app/renderer/styles/terminal-node.theme-opencode.css', OPENCODE_TERMINAL_COLORS], + ['src/app/renderer/styles/workspace-agent-item.css', AGENT_PROVIDER_BRAND_COLORS], +]) + +function normalizePath(filePath) { + return filePath.replaceAll('\\', '/') +} + +function canonicalizeRawColor(color) { + return color.toLowerCase().replace(/\s+/g, ' ').trim() +} + +function stripCommentsPreservingLocations(content) { + return content.replace(/\/\*[\s\S]*?\*\//g, comment => comment.replace(/[^\r\n]/g, ' ')) +} + +function isTokenSource(filePath) { + return filePath === BASE_TOKEN_FILE || filePath.startsWith(THEME_DIRECTORY) +} + +function locationAt(content, index) { + const prefix = content.slice(0, index) + const line = prefix.split(/\r\n|\r|\n/).length + const lineStart = Math.max(prefix.lastIndexOf('\n'), prefix.lastIndexOf('\r')) + 1 + const lineEndMatch = content.slice(index).search(/\r|\n/) + const lineEnd = lineEndMatch === -1 ? content.length : index + lineEndMatch + + return { + line, + column: index - lineStart + 1, + excerpt: content.slice(lineStart, lineEnd).trim(), + } +} + +function makeViolation({ file, content, index, rule, message }) { + return { + file, + rule, + message, + ...locationAt(content, index), + } +} + +function isAllowedRawColor(filePath, rawColor, allowlist) { + const allowedColors = allowlist.get(filePath) + return allowedColors?.has(canonicalizeRawColor(rawColor)) ?? false +} + +export function checkUiStyleFiles(files, { rawColorAllowlist = DEFAULT_RAW_COLOR_ALLOWLIST } = {}) { + const normalizedFiles = files.map(file => { + const content = file.content ?? '' + return { + path: normalizePath(file.path), + content, + source: stripCommentsPreservingLocations(content), + } + }) + const declaredTokens = new Set() + const tokenUsages = [] + const violations = [] + + for (const file of normalizedFiles) { + for (const match of file.source.matchAll(TOKEN_DECLARATION_PATTERN)) { + declaredTokens.add(match[1].toLowerCase()) + } + + for (const match of file.source.matchAll(TOKEN_USAGE_PATTERN)) { + tokenUsages.push({ file, index: match.index, token: match[1].toLowerCase() }) + } + + if (isTokenSource(file.path)) { + continue + } + + for (const match of file.source.matchAll(RAW_COLOR_PATTERN)) { + if (isAllowedRawColor(file.path, match[0], rawColorAllowlist)) { + continue + } + + violations.push( + makeViolation({ + file: file.path, + content: file.content, + index: match.index, + rule: 'raw-color', + message: `Replace raw color \`${match[0]}\` with a declared \`--cove-*\` token.`, + }), + ) + } + + for (const match of file.source.matchAll(THEME_BRANCH_PATTERN)) { + violations.push( + makeViolation({ + file: file.path, + content: file.content, + index: match.index, + rule: 'component-theme-branch', + message: 'Move `data-cove-theme` branching into base.css or styles/themes/**.', + }), + ) + } + } + + for (const usage of tokenUsages) { + if (declaredTokens.has(usage.token)) { + continue + } + + violations.push( + makeViolation({ + file: usage.file.path, + content: usage.file.content, + index: usage.index, + rule: 'undeclared-token', + message: `Declare \`${usage.token}\` before using it.`, + }), + ) + } + + return violations.sort( + (left, right) => + left.file.localeCompare(right.file) || + left.line - right.line || + left.column - right.column || + left.rule.localeCompare(right.rule), + ) +} + +function collectCssFiles(directory, repoRoot, files) { + for (const entry of readdirSync(directory, { withFileTypes: true })) { + const absolutePath = resolve(directory, entry.name) + if (entry.isDirectory()) { + collectCssFiles(absolutePath, repoRoot, files) + continue + } + if (!entry.isFile() || !entry.name.endsWith('.css')) { + continue + } + + files.push({ + path: normalizePath(relative(repoRoot, absolutePath)), + content: readFileSync(absolutePath, 'utf8'), + }) + } +} + +export function readUiStyleFiles(repoRoot = DEFAULT_REPO_ROOT) { + const files = [] + collectCssFiles(resolve(repoRoot, UI_STYLE_ROOT), repoRoot, files) + return files +} + +export function formatUiStyleViolations(violations) { + if (violations.length === 0) { + return '' + } + + const lines = [`[ui-style-check] Found ${violations.length} UI style violation(s):`] + for (const violation of violations) { + lines.push( + `- ${violation.file}:${violation.line}:${violation.column} [${violation.rule}]`, + ` ${violation.message}`, + ` ${violation.excerpt}`, + ) + } + lines.push('', 'See docs/ui/README.md for the token and theme rules.') + return `${lines.join('\n')}\n` +} + +export function runUiStyleCheck({ repoRoot = DEFAULT_REPO_ROOT, stderr = process.stderr } = {}) { + const violations = checkUiStyleFiles(readUiStyleFiles(repoRoot)) + if (violations.length === 0) { + return 0 + } + + stderr.write(formatUiStyleViolations(violations)) + return 1 +} + +const invokedPath = process.argv[1] ? pathToFileURL(resolve(process.argv[1])).href : null +if (invokedPath === import.meta.url) { + process.exitCode = runUiStyleCheck() +} diff --git a/src/app/renderer/components/CoveSelect.tsx b/src/app/renderer/components/CoveSelect.tsx index 197e18158..8a10e10f1 100644 --- a/src/app/renderer/components/CoveSelect.tsx +++ b/src/app/renderer/components/CoveSelect.tsx @@ -1,6 +1,7 @@ import React, { useCallback, useEffect, useId, useMemo, useRef, useState } from 'react' import { createPortal } from 'react-dom' import { ChevronDown } from 'lucide-react' +import { DismissableLayer } from './ui/DismissableLayer' export interface CoveSelectOption { value: string @@ -142,29 +143,14 @@ export function CoveSelect({ updateMenuPosition() - const handlePointerDown = (event: PointerEvent) => { - const target = event.target - if (!(target instanceof Node)) { - return - } - - if (rootRef.current?.contains(target) || menuRef.current?.contains(target)) { - return - } - - closeMenu() - } - const handleWindowChange = () => { updateMenuPosition() } - document.addEventListener('pointerdown', handlePointerDown, true) window.addEventListener('resize', handleWindowChange) window.addEventListener('scroll', handleWindowChange, true) return () => { - document.removeEventListener('pointerdown', handlePointerDown, true) window.removeEventListener('resize', handleWindowChange) window.removeEventListener('scroll', handleWindowChange, true) } @@ -337,12 +323,19 @@ export function CoveSelect({ {isOpen && menuPosition ? createPortal( -
+ {description} +
+ ) : null} +Keeps navigation visible.
+