From d727b19794d68529ee4f8693c2e200837d08beb3 Mon Sep 17 00:00:00 2001 From: DeadWaveWave <90459902+DeadWaveWave@users.noreply.github.com> Date: Sat, 17 Oct 2026 13:13:03 +0800 Subject: [PATCH 1/3] feat(ui): make the control center quick switches MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The control center now holds quick switches only: theme family (Cove or Pixel) and light, dark or follow-system mode as segmented controls, pixel background, motion signals (creation, close and working pixel effects together) and the minimap. Pixel offers no follow-system mode, so switching to it keeps what the system shows. Plugin themes and theme previews stay in Settings → Appearance; an active plugin theme is named here, with the existing unavailable and load-failure notices. The standby banner switch leaves the control center (it is in Settings) and is renamed "Notification banner". Co-Authored-By: Claude Opus 5.5 --- scripts/ui-style-ratchet.json | 6 +- .../renderer/i18n/locales/en.controlCenter.ts | 20 ++ .../renderer/i18n/locales/en.settingsPanel.ts | 2 +- src/app/renderer/i18n/locales/en.ts | 15 +- .../i18n/locales/zh-CN.controlCenter.ts | 20 ++ .../i18n/locales/zh-CN.settingsPanel.ts | 4 +- src/app/renderer/i18n/locales/zh-CN.ts | 15 +- .../shell/components/AppShellOverlays.tsx | 16 +- .../shell/components/ControlCenter.spec.tsx | 101 +++---- .../shell/components/ControlCenter.tsx | 250 +++++++++--------- src/app/renderer/styles/base.css | 1 - src/app/renderer/styles/control-center.css | 152 +---------- .../settings/domain/uiThemeChoice.spec.ts | 32 +++ src/contexts/settings/domain/uiThemeChoice.ts | 51 ++++ .../workerWebCanvas.themes.spec.ts | 28 +- tests/e2e/command-center.spec.ts | 3 +- tests/e2e/control-center.spec.ts | 37 +-- ...anvas.agent-standby-banner-context.spec.ts | 11 +- .../e2e/workspace-canvas.pixel-light.spec.ts | 6 +- tests/e2e/workspace-search.spec.ts | 3 +- tests/unit/contexts/pixelEffectTheme.spec.ts | 12 +- 21 files changed, 387 insertions(+), 398 deletions(-) create mode 100644 src/app/renderer/i18n/locales/en.controlCenter.ts create mode 100644 src/app/renderer/i18n/locales/zh-CN.controlCenter.ts create mode 100644 src/contexts/settings/domain/uiThemeChoice.spec.ts create mode 100644 src/contexts/settings/domain/uiThemeChoice.ts diff --git a/scripts/ui-style-ratchet.json b/scripts/ui-style-ratchet.json index c5ee4c1b9..5082c710e 100644 --- a/scripts/ui-style-ratchet.json +++ b/scripts/ui-style-ratchet.json @@ -8,7 +8,7 @@ "src/app/renderer/styles/base.css": 1, "src/app/renderer/styles/canvas-node-window.css": 1, "src/app/renderer/styles/command-center.css": 6, - "src/app/renderer/styles/control-center.css": 7, + "src/app/renderer/styles/control-center.css": 5, "src/app/renderer/styles/cove-fields.css": 5, "src/app/renderer/styles/cove-window.css": 9, "src/app/renderer/styles/cove-window.responsive.css": 1, @@ -86,7 +86,7 @@ "src/app/renderer/styles/app-startup-state.css": 12, "src/app/renderer/styles/base.css": 1, "src/app/renderer/styles/command-center.css": 4, - "src/app/renderer/styles/control-center.css": 5, + "src/app/renderer/styles/control-center.css": 3, "src/app/renderer/styles/cove-window.css": 4, "src/app/renderer/styles/document-node.css": 2, "src/app/renderer/styles/image-node.css": 1, @@ -148,7 +148,7 @@ "src/app/renderer/styles/app-notifications.css": 7, "src/app/renderer/styles/app-startup-state.css": 19, "src/app/renderer/styles/command-center.css": 18, - "src/app/renderer/styles/control-center.css": 16, + "src/app/renderer/styles/control-center.css": 14, "src/app/renderer/styles/cove-fields.css": 9, "src/app/renderer/styles/cove-window.css": 17, "src/app/renderer/styles/cove-window.responsive.css": 3, diff --git a/src/app/renderer/i18n/locales/en.controlCenter.ts b/src/app/renderer/i18n/locales/en.controlCenter.ts new file mode 100644 index 000000000..7466f8815 --- /dev/null +++ b/src/app/renderer/i18n/locales/en.controlCenter.ts @@ -0,0 +1,20 @@ +export const enControlCenter = { + controlCenter: { + open: 'Control Center', + title: 'Control Center', + quickSettings: 'Quick settings', + sidebar: 'Sidebar', + minimap: 'Minimap', + theme: 'Theme', + familyCove: 'Cove', + familyPixel: 'Pixel', + mode: 'Light or dark', + pixelBackground: 'Pixel background', + motionSignals: 'Motion signals', + moreSettings: 'Theme and display settings', + shown: 'Shown', + hidden: 'Hidden', + on: 'On', + off: 'Off', + }, +} as const diff --git a/src/app/renderer/i18n/locales/en.settingsPanel.ts b/src/app/renderer/i18n/locales/en.settingsPanel.ts index f42851d83..a6c0dcfef 100644 --- a/src/app/renderer/i18n/locales/en.settingsPanel.ts +++ b/src/app/renderer/i18n/locales/en.settingsPanel.ts @@ -148,7 +148,7 @@ export const enSettingsPanel = { 'Show a native system notification when an agent transitions from working to standby.', }, agentStandbyBanner: { - enabledLabel: 'Top-right banner', + enabledLabel: 'Notification banner', enabledHelp: 'Show an in-app banner when an agent transitions from working to standby.', contextTitle: 'Banner context', contextHelp: 'Choose what context chips are shown in the banner.', diff --git a/src/app/renderer/i18n/locales/en.ts b/src/app/renderer/i18n/locales/en.ts index 904a542c4..e57fde544 100644 --- a/src/app/renderer/i18n/locales/en.ts +++ b/src/app/renderer/i18n/locales/en.ts @@ -12,6 +12,7 @@ import { enRole } from './en.role' import { enWorkspaceCanvas } from './en.workspaceCanvas' import { enSidebarView } from './en.sidebarView' import { enCanvasCollapse } from './en.canvasCollapse' +import { enControlCenter } from './en.controlCenter' import { enSpaceCollapse } from './en.spaceCollapse' export const en = { @@ -30,6 +31,7 @@ export const en = { 'This theme is unavailable. The default theme is shown until it is enabled again.', }, ...enCanvasCollapse, + ...enControlCenter, ...enSpaceCollapse, common: { actions: 'Actions', @@ -131,19 +133,6 @@ export const en = { restartToUpdateShort: 'Restart', restartToUpdateTitle: 'Version {{version}} is ready to install', }, - controlCenter: { - open: 'Control Center', - title: 'Control Center', - quickSettings: 'Quick settings', - sidebar: 'Sidebar', - minimap: 'Minimap', - theme: 'Theme', - agentStandbyBanner: 'Top-right banner', - shown: 'Shown', - hidden: 'Hidden', - on: 'On', - off: 'Off', - }, whatsNew: { title: "What's New", subtitleRange: 'Updated from {{fromVersion}} to {{toVersion}}.', diff --git a/src/app/renderer/i18n/locales/zh-CN.controlCenter.ts b/src/app/renderer/i18n/locales/zh-CN.controlCenter.ts new file mode 100644 index 000000000..17d84634c --- /dev/null +++ b/src/app/renderer/i18n/locales/zh-CN.controlCenter.ts @@ -0,0 +1,20 @@ +export const zhCNControlCenter = { + controlCenter: { + open: '控制中心', + title: '控制中心', + quickSettings: '快捷设置', + sidebar: '侧边栏', + minimap: '缩略图', + theme: '主题', + familyCove: 'Cove', + familyPixel: 'Pixel', + mode: '明暗', + pixelBackground: '像素背景', + motionSignals: '动态信号', + moreSettings: '主题与显示设置', + shown: '已显示', + hidden: '已隐藏', + on: '已开启', + off: '已关闭', + }, +} as const diff --git a/src/app/renderer/i18n/locales/zh-CN.settingsPanel.ts b/src/app/renderer/i18n/locales/zh-CN.settingsPanel.ts index eb154f200..ecfadbd03 100644 --- a/src/app/renderer/i18n/locales/zh-CN.settingsPanel.ts +++ b/src/app/renderer/i18n/locales/zh-CN.settingsPanel.ts @@ -156,8 +156,8 @@ export const zhCNSettingsPanel = { enabledHelp: '当 Agent 从工作状态变为待命时,显示原生系统通知。', }, agentStandbyBanner: { - enabledLabel: '右上角横幅', - enabledHelp: '当 Agent 从工作状态变为待命时,显示应用内右上角横幅。', + enabledLabel: '通知横幅', + enabledHelp: '当 Agent 从工作状态变为待命时,在应用内显示通知横幅。', contextTitle: '横幅信息', contextHelp: '控制右上角提醒横幅中显示的上下文信息。', showTask: '显示任务', diff --git a/src/app/renderer/i18n/locales/zh-CN.ts b/src/app/renderer/i18n/locales/zh-CN.ts index 57a28b5ca..01ea6a0da 100644 --- a/src/app/renderer/i18n/locales/zh-CN.ts +++ b/src/app/renderer/i18n/locales/zh-CN.ts @@ -5,6 +5,7 @@ import { zhCNCommonRemoteEndpoints } from './zh-CN.commonRemoteEndpoints' import { zhCNMessages } from './zh-CN.messages' import { zhCNSidebarView } from './zh-CN.sidebarView' import { zhCNCanvasCollapse } from './zh-CN.canvasCollapse' +import { zhCNControlCenter } from './zh-CN.controlCenter' import { zhCNSpaceCollapse } from './zh-CN.spaceCollapse' import { zhCNShell } from './zh-CN.shell' import { zhCNSpaceExplorer } from './zh-CN.spaceExplorer' @@ -30,6 +31,7 @@ export const zhCN = { unavailableTheme: '此主题暂不可用。启用后将恢复,目前显示默认主题。', }, ...zhCNCanvasCollapse, + ...zhCNControlCenter, ...zhCNSpaceCollapse, common: { actions: '操作', @@ -130,19 +132,6 @@ export const zhCN = { restartToUpdateShort: '重启更新', restartToUpdateTitle: '版本 {{version}} 已准备好安装', }, - controlCenter: { - open: '控制中心', - title: '控制中心', - quickSettings: '快捷设置', - sidebar: '侧边栏', - minimap: '缩略图', - theme: '主题', - agentStandbyBanner: '右上角横幅', - shown: '已显示', - hidden: '已隐藏', - on: '已开启', - off: '已关闭', - }, whatsNew: { title: '更新内容', subtitleRange: '从 {{fromVersion}} 更新到 {{toVersion}}。', diff --git a/src/app/renderer/shell/components/AppShellOverlays.tsx b/src/app/renderer/shell/components/AppShellOverlays.tsx index 830cdb6f1..c7eb564b1 100644 --- a/src/app/renderer/shell/components/AppShellOverlays.tsx +++ b/src/app/renderer/shell/components/AppShellOverlays.tsx @@ -62,7 +62,12 @@ export function AppShellOverlays({ anchorRef={controlCenterAnchorRef} uiTheme={agentSettings.uiTheme} isMinimapVisible={activeWorkspace?.isMinimapVisible ?? false} - isStandbyBannerEnabled={agentSettings.standbyBannerEnabled} + isPixelBackgroundEnabled={agentSettings.canvasPixelBackgroundEnabled} + areMotionSignalsEnabled={ + agentSettings.nodeCreationPixelEffectEnabled && + agentSettings.nodeClosePixelEffectEnabled && + agentSettings.agentWorkingPixelEffectEnabled + } hasActiveWorkspace={activeWorkspace !== null} onClose={() => { onCloseControlCenter() @@ -80,10 +85,15 @@ export function AppShellOverlays({ onMinimapVisibilityChange(!activeWorkspace.isMinimapVisible) }} - onToggleStandbyBanner={() => { + onChangePixelBackground={enabled => { + setAgentSettings(prev => ({ ...prev, canvasPixelBackgroundEnabled: enabled })) + }} + onChangeMotionSignals={enabled => { setAgentSettings(prev => ({ ...prev, - standbyBannerEnabled: !prev.standbyBannerEnabled, + nodeCreationPixelEffectEnabled: enabled, + nodeClosePixelEffectEnabled: enabled, + agentWorkingPixelEffectEnabled: enabled, })) }} onOpenSettings={() => { diff --git a/src/app/renderer/shell/components/ControlCenter.spec.tsx b/src/app/renderer/shell/components/ControlCenter.spec.tsx index 9eb62f755..f03577192 100644 --- a/src/app/renderer/shell/components/ControlCenter.spec.tsx +++ b/src/app/renderer/shell/components/ControlCenter.spec.tsx @@ -3,7 +3,7 @@ import { act, cleanup, fireEvent, render, screen } from '@testing-library/react' import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' import { Dialog } from '@app/renderer/components/ui/Dialog' import { applyUiLanguage } from '@app/renderer/i18n' -import { UI_THEMES, type UiTheme } from '@contexts/settings/domain/agentSettings' +import type { UiTheme } from '@contexts/settings/domain/agentSettings' import { examplePlugin } from '@contexts/plugins/presentation/renderer/pluginPresentationTestFixture' import * as registry from '@contexts/plugins/presentation/renderer/usePluginRegistry' import * as themeApplication from '@contexts/plugins/presentation/renderer/themeApplication' @@ -12,7 +12,8 @@ import { ControlCenter } from './ControlCenter' const actions = { onChangeUiTheme: vi.fn(), onToggleMinimap: vi.fn(), - onToggleStandbyBanner: vi.fn(), + onChangePixelBackground: vi.fn(), + onChangeMotionSignals: vi.fn(), } function Harness({ @@ -40,7 +41,8 @@ function Harness({ anchorRef={anchorRef} uiTheme={uiTheme} isMinimapVisible - isStandbyBannerEnabled + isPixelBackgroundEnabled + areMotionSignalsEnabled={false} hasActiveWorkspace={hasActiveWorkspace} onClose={() => setOpen(false)} onOpenSettings={() => setSettingsOpen(true)} @@ -95,33 +97,47 @@ describe('Control Center surfaces', () => { setViewportWidth(originalWidth) }) - it('keeps desktop nonmodal, exposes all theme choices, and focuses an enabled switch', () => { - render() + it('keeps desktop nonmodal and offers theme family, mode and display switches', () => { + render() openControls() const surface = screen.getByRole('dialog', { name: 'Control Center' }) const minimap = screen.getByRole('switch', { name: 'Minimap' }) as HTMLInputElement - const banner = screen - .getByTestId('control-center-toggle-agent-standby-banner') - .querySelector('input') + const pixelBackground = screen.getByRole('switch', { name: 'Pixel background' }) expect(surface.getAttribute('aria-modal')).toBeNull() expect(minimap.disabled).toBe(true) - expect(document.activeElement).toBe(banner) + expect(document.activeElement).toBe(pixelBackground) expect(screen.getByRole('radiogroup', { name: 'Theme' })).not.toBeNull() - expect(screen.getAllByRole('radio')).toHaveLength(UI_THEMES.length) - expect(surface.querySelector('.control-center__row-status')).toBeNull() + expect((screen.getByRole('radio', { name: 'Cove' }) as HTMLInputElement).checked).toBe(true) + expect((screen.getByRole('radio', { name: 'Dark' }) as HTMLInputElement).checked).toBe(true) + // The notification banner lives in Settings now. + expect(screen.queryByTestId('control-center-toggle-agent-standby-banner')).toBeNull() - fireEvent.click(screen.getByTestId('control-center-theme-pixel-light')) - expect(actions.onChangeUiTheme).toHaveBeenCalledWith('pixel-light') - fireEvent.click(banner!) - expect(actions.onToggleStandbyBanner).toHaveBeenCalledOnce() + fireEvent.click(screen.getByRole('radio', { name: 'Pixel' })) + expect(actions.onChangeUiTheme).toHaveBeenLastCalledWith('pixel') + fireEvent.click(screen.getByRole('radio', { name: 'Follow System' })) + expect(actions.onChangeUiTheme).toHaveBeenLastCalledWith('system') + fireEvent.click(pixelBackground) + expect(actions.onChangePixelBackground).toHaveBeenCalledWith(false) + fireEvent.click(screen.getByRole('switch', { name: 'Motion signals' })) + expect(actions.onChangeMotionSignals).toHaveBeenCalledWith(true) expect(actions.onToggleMinimap).not.toHaveBeenCalled() const outside = screen.getByRole('button', { name: 'Outside action' }) outside.focus() expect(document.activeElement).toBe(outside) }) + it('offers only the modes the current family has', () => { + render() + openControls() + expect((screen.getByRole('radio', { name: 'Pixel' }) as HTMLInputElement).checked).toBe(true) + expect((screen.getByRole('radio', { name: 'Light' }) as HTMLInputElement).checked).toBe(true) + expect(screen.queryByRole('radio', { name: 'Follow System' })).toBeNull() + fireEvent.click(screen.getByRole('radio', { name: 'Cove' })) + expect(actions.onChangeUiTheme).toHaveBeenLastCalledWith('light') + }) + it.each(['en', 'zh-CN'] as const)( - 'lists available plugin themes with readable names in %s', + 'names an active plugin theme and leaves its mode to Settings in %s', async language => { await applyUiLanguage(language) const themedPlugin = { @@ -136,24 +152,25 @@ describe('Control Center surfaces', () => { ], } vi.mocked(registry.usePluginRegistry).mockReturnValue({ - plugins: [ - themedPlugin, - { ...themedPlugin, id: 'disabled', enabled: false }, - { ...themedPlugin, id: 'broken', status: 'error' }, - ], + plugins: [themedPlugin], error: null, }) - const { rerender } = render() + render() openControls() - expect(screen.getAllByRole('radio')).toHaveLength(UI_THEMES.length + 1) - const choice = screen.getByRole('radio', { - name: 'Prism Night Example Tool', - }) as HTMLInputElement - expect(choice.checked).toBe(true) expect(screen.getByTestId('control-center-current-theme').textContent).toBe('Prism Night') - rerender() - fireEvent.click(choice) - expect(actions.onChangeUiTheme).toHaveBeenLastCalledWith('plugin:example-tool:night') + const families = screen.getAllByRole('radio').slice(0, 2) as HTMLInputElement[] + expect(families.every(radio => !radio.checked)).toBe(true) + expect( + ( + screen + .getByTestId('control-center-theme-mode') + .querySelectorAll('input') as NodeListOf + )[0]?.disabled, + ).toBe(true) + document.documentElement.dataset.coveTheme = 'dark' + fireEvent.click(families[0]!) + expect(actions.onChangeUiTheme).toHaveBeenLastCalledWith('dark') + delete document.documentElement.dataset.coveTheme }, ) @@ -170,17 +187,12 @@ describe('Control Center surfaces', () => { error: null, }) rerender() - expect(screen.queryByTestId('control-center-theme-plugin:example-tool:night')).toBeNull() expect(screen.getByRole('status').textContent).toContain('unavailable') - // The unavailable theme falls back to the default theme, Cove. - expect(screen.getByTestId('control-center-current-theme').textContent).toBe('Cove') + expect(screen.queryByTestId('control-center-current-theme')).toBeNull() expect(actions.onChangeUiTheme).not.toHaveBeenCalled() vi.mocked(registry.usePluginRegistry).mockReturnValue({ plugins: [plugin], error: null }) rerender() - expect( - (screen.getByTestId('control-center-theme-plugin:example-tool:night') as HTMLInputElement) - .checked, - ).toBe(true) + expect(screen.getByTestId('control-center-current-theme').textContent).toBe('Prism Night') expect(screen.queryByRole('status')).toBeNull() expect(actions.onChangeUiTheme).not.toHaveBeenCalled() }) @@ -199,12 +211,7 @@ describe('Control Center surfaces', () => { }) render() openControls() - expect(screen.getByTestId('control-center-current-theme').textContent).toBe('Cove') expect(screen.getByRole('status').textContent).toContain('could not be loaded') - expect( - (screen.getByTestId('control-center-theme-plugin:example-tool:night') as HTMLInputElement) - .checked, - ).toBe(true) expect(actions.onChangeUiTheme).not.toHaveBeenCalled() }) @@ -213,12 +220,12 @@ describe('Control Center surfaces', () => { render() const trigger = openControls() const surface = screen.getByRole('dialog', { name: 'Control Center' }) - const minimap = screen.getByRole('switch', { name: 'Minimap' }) + const pixelBackground = screen.getByRole('switch', { name: 'Pixel background' }) expect(surface.getAttribute('aria-modal')).toBe('true') expect(screen.getByTestId('app-shell').inert).toBe(true) - expect(document.activeElement).toBe(minimap) + expect(document.activeElement).toBe(pixelBackground) screen.getByRole('button', { name: 'Outside action' }).focus() - expect(document.activeElement).toBe(minimap) + expect(document.activeElement).toBe(pixelBackground) const close = screen.getByTestId('control-center-close') const settings = screen.getByTestId('control-center-open-settings') settings.focus() @@ -255,9 +262,9 @@ describe('Control Center surfaces', () => { act(() => vi.runOnlyPendingTimers()) expect(screen.getByTestId('app-shell').inert).toBe(false) expect(screen.getByTestId('control-center').getAttribute('aria-modal')).toBeNull() - expect(document.activeElement).toBe(screen.getByRole('switch', { name: 'Minimap' })) + expect(document.activeElement).toBe(screen.getByRole('switch', { name: 'Pixel background' })) expect(actions.onChangeUiTheme).not.toHaveBeenCalled() expect(actions.onToggleMinimap).not.toHaveBeenCalled() - expect(actions.onToggleStandbyBanner).not.toHaveBeenCalled() + expect(actions.onChangePixelBackground).not.toHaveBeenCalled() }) }) diff --git a/src/app/renderer/shell/components/ControlCenter.tsx b/src/app/renderer/shell/components/ControlCenter.tsx index ab7fdf4f6..ab3b75084 100644 --- a/src/app/renderer/shell/components/ControlCenter.tsx +++ b/src/app/renderer/shell/components/ControlCenter.tsx @@ -1,92 +1,72 @@ import React, { useEffect, useId, useRef, useState } from 'react' import { - Bell, - Check, ChevronRight, + Grid3x3, Map as MapIcon, Settings, SlidersHorizontal, + Sparkles, X, } from 'lucide-react' import { Button } from '@app/renderer/components/ui/Button' import { Dialog } from '@app/renderer/components/ui/Dialog' import { IconButton } from '@app/renderer/components/ui/IconButton' import { Popover } from '@app/renderer/components/ui/Popover' +import { SegmentedControl } from '@app/renderer/components/ui/SegmentedControl' import { Toggle } from '@app/renderer/components/ui/Toggle' import { useCompactViewport } from '@app/renderer/components/useCompactViewport' import { useTranslation } from '@app/renderer/i18n' -import { getUiThemeLabel } from '@app/renderer/i18n/labels' -import { UI_THEMES, type UiTheme } from '@contexts/settings/domain/agentSettings' +import type { UiTheme } from '@contexts/settings/domain/agentSettings' +import type { UiThemeFamily } from '@contexts/settings/domain/uiSettings' import { - DEFAULT_UI_THEME, - UI_THEME_DESCRIPTORS, - type UiThemeBaseScheme, -} from '@contexts/settings/domain/uiSettings' + describeUiThemeChoice, + resolveUiThemeChoice, + uiThemeModesFor, + type UiThemeMode, +} from '@contexts/settings/domain/uiThemeChoice' import { availablePluginThemes } from '@contexts/plugins/domain/pluginThemes' import { usePluginRegistry } from '@contexts/plugins/presentation/renderer/usePluginRegistry' import { useThemeApplicationStatus } from '@contexts/plugins/presentation/renderer/themeApplication' +const UI_THEME_FAMILIES: UiThemeFamily[] = ['cove', 'pixel'] + export function ControlCenter({ isOpen, anchorRef, uiTheme, isMinimapVisible, - isStandbyBannerEnabled, + isPixelBackgroundEnabled, + areMotionSignalsEnabled, hasActiveWorkspace, onClose, onChangeUiTheme, onToggleMinimap, - onToggleStandbyBanner, + onChangePixelBackground, + onChangeMotionSignals, onOpenSettings, }: { isOpen: boolean anchorRef: React.RefObject uiTheme: UiTheme isMinimapVisible: boolean - isStandbyBannerEnabled: boolean + isPixelBackgroundEnabled: boolean + /** On when every pixel signal (creation, close, working) is on. */ + areMotionSignalsEnabled: boolean hasActiveWorkspace: boolean onClose: () => void onChangeUiTheme: (theme: UiTheme) => void onToggleMinimap: () => void - onToggleStandbyBanner: () => void + onChangePixelBackground: (enabled: boolean) => void + onChangeMotionSignals: (enabled: boolean) => void onOpenSettings: () => void }): React.JSX.Element | null { const { t } = useTranslation() const { plugins } = usePluginRegistry() const applied = useThemeApplicationStatus() - const pluginThemes = availablePluginThemes(plugins) - const themeChoices: Array<{ - id: UiTheme - title: string - appearance: UiThemeBaseScheme - preview: string - pluginName?: string - style?: React.CSSProperties - }> = [ - ...UI_THEMES.map(id => ({ - id, - title: getUiThemeLabel(t, id), - appearance: UI_THEME_DESCRIPTORS[id].baseScheme, - preview: id, - })), - ...pluginThemes.map(({ id, plugin, theme }) => ({ - id, - title: theme.title, - appearance: theme.appearance, - preview: theme.extends ?? theme.appearance, - pluginName: plugin.displayName, - style: - theme.tokens['--cove-app-background'] || theme.tokens['--cove-accent'] - ? ({ - '--cove-control-theme-surface': theme.tokens['--cove-app-background'], - '--cove-control-theme-accent': theme.tokens['--cove-accent'], - } as React.CSSProperties) - : undefined, - })), - ] - const selectedChoice = themeChoices.find(choice => choice.id === uiTheme) - const isUnavailable = uiTheme.startsWith('plugin:') && !selectedChoice + const pluginTheme = availablePluginThemes(plugins).find(choice => choice.id === uiTheme) + const isUnavailable = uiTheme.startsWith('plugin:') && !pluginTheme const hasLoadError = applied.requestedId === uiTheme && Boolean(applied.error) + const choice = describeUiThemeChoice(uiTheme) const isCompact = useCompactViewport() const titleId = useId() const initialFocusRef = useRef(null) @@ -110,6 +90,18 @@ export function ControlCenter({ } }, [isCompact, isOpen]) + const prefersDark = () => + typeof window.matchMedia === 'function' && + window.matchMedia('(prefers-color-scheme: dark)').matches + // A plugin theme has no mode here; switching family keeps what the window shows now. + const currentMode = + choice.mode ?? (document.documentElement.dataset.coveTheme === 'light' ? 'light' : 'dark') + const modeLabels: Record = { + light: t('settingsPanel.appearanceNext.light'), + dark: t('settingsPanel.appearanceNext.dark'), + system: t('settingsPanel.appearanceNext.system'), + } + const content = ( <>
@@ -127,97 +119,54 @@ export function ControlCenter({
-
- - - -
-

{t('controlCenter.theme')}

- - {hasLoadError - ? getUiThemeLabel(t, DEFAULT_UI_THEME) - : (selectedChoice?.title ?? getUiThemeLabel(t, DEFAULT_UI_THEME))} - -
-
- {themeChoices.map(theme => ( - - ))} + {pluginTheme.theme.title} + + ) : null}
+ ({ + value: family, + label: + family === 'pixel' ? t('controlCenter.familyPixel') : t('controlCenter.familyCove'), + testId: `control-center-theme-family-${family}`, + }))} + onValueChange={family => { + if (family !== '') { + onChangeUiTheme(resolveUiThemeChoice(family, currentMode, prefersDark())) + } + }} + /> + ({ + value: mode, + label: modeLabels[mode], + testId: `control-center-theme-mode-${mode}`, + }))} + onValueChange={mode => { + if (mode !== '' && choice.family) { + onChangeUiTheme(resolveUiThemeChoice(choice.family, mode, prefersDark())) + } + }} + /> {isUnavailable || hasLoadError ? (

{t( @@ -229,6 +178,49 @@ export function ControlCenter({ ) : null}

+
+ + + +
+
diff --git a/src/app/renderer/styles/base.css b/src/app/renderer/styles/base.css index 4d7a15a81..87ef882b1 100644 --- a/src/app/renderer/styles/base.css +++ b/src/app/renderer/styles/base.css @@ -104,7 +104,6 @@ --cove-theme-preview-dark: #0c1217; --cove-theme-preview-accent: #00b0d5; --cove-theme-preview-pixel-accent: #5b9cf0; - --cove-theme-preview-pixel: linear-gradient(135deg, #5b9cf0, #55bddc 50%, #70ddbc); --cove-theme-preview-pixel-light: linear-gradient( 135deg, #e7f0f6 48%, diff --git a/src/app/renderer/styles/control-center.css b/src/app/renderer/styles/control-center.css index fe42e788a..ed7911526 100644 --- a/src/app/renderer/styles/control-center.css +++ b/src/app/renderer/styles/control-center.css @@ -59,6 +59,7 @@ .control-center__toggles { padding: 6px 8px; + border-top: 1px solid var(--cove-border-subtle); } .control-center__row { @@ -110,9 +111,8 @@ .control-center__section { display: flex; flex-direction: column; - gap: 6px; - padding: 10px 8px; - border-top: 1px solid var(--cove-border-subtle); + gap: 8px; + padding: 10px 8px 12px; } .control-center__section-heading { @@ -129,135 +129,13 @@ font-size: calc(11px * var(--cove-ui-font-scale)); } -.control-center__themes { - display: grid; - grid-template-columns: repeat(2, minmax(0, 1fr)); - gap: 2px 6px; -} - -.control-center__theme { - position: relative; - min-width: 0; - cursor: pointer; -} - -.control-center__theme[data-theme-choice='system'], -.control-center__theme[data-plugin-theme] { - grid-column: 1 / -1; -} - -.control-center__theme > input { - position: absolute; - z-index: 2; - inset: 0; - width: 100%; - height: 100%; - margin: 0; - opacity: 0; - cursor: pointer; -} - -.control-center__theme-option { - position: relative; - display: grid; - grid-template-columns: 20px minmax(0, 1fr) 14px; - min-height: 36px; - align-items: center; - gap: 8px; - padding: 7px 8px; - border: 1px solid transparent; - border-radius: 4px; - background: transparent; - color: var(--cove-text-muted); - transition: color var(--cove-motion-fast) var(--cove-ease-standard); -} - -.control-center__theme:hover .control-center__theme-option { - background: var(--cove-control-hover); - color: var(--cove-text); -} - -.control-center__theme > input:focus-visible + .control-center__theme-option { - border-color: var(--cove-focus-ring); - box-shadow: var(--cove-focus-shadow); -} - -.control-center__theme > input:checked + .control-center__theme-option { - background: var(--cove-control-selected); - color: var(--cove-text); -} - -.control-center__theme-check { - visibility: hidden; - color: var(--cove-accent); -} - -.control-center__theme - > input:checked - + .control-center__theme-option - .control-center__theme-check { - visibility: visible; -} - -.control-center__theme-preview { - --cove-control-theme-surface: initial; - --cove-control-theme-accent: initial; - - width: 20px; - height: 16px; - flex: 0 0 auto; - border: 1px solid var(--cove-border-strong); - border-radius: 3px; - box-shadow: inset 0 0 0 2px var(--cove-menu-surface); -} - -.control-center__theme-preview[data-theme-preview='system'] { - background: linear-gradient( - 90deg, - var(--cove-theme-preview-light) 50%, - var(--cove-theme-preview-dark) 50% - ); -} - -.control-center__theme-preview[data-theme-preview='light'] { - background: var(--cove-theme-preview-light); -} - -.control-center__theme-preview[data-theme-preview='dark'] { - background: var(--cove-theme-preview-dark); -} - -.control-center__theme-preview[data-theme-preview='pixel'] { - border-radius: var(--cove-radius-xs); - background: var(--cove-theme-preview-pixel); -} - -.control-center__theme-preview[data-theme-preview='pixel-light'] { - border-radius: var(--cove-radius-xs); - background: var(--cove-theme-preview-pixel-light); -} - -.control-center__theme-preview[data-theme-colors] { - background: linear-gradient( - 135deg, - var(--cove-control-theme-surface, var(--cove-theme-preview-dark)) 48%, - var(--cove-control-theme-accent, var(--cove-pixel-cyan)) 52% - ); -} - -.control-center__theme-preview[data-theme-colors][data-theme-scheme='light'] { - background: linear-gradient( - 135deg, - var(--cove-control-theme-surface, var(--cove-theme-preview-light)) 48%, - var(--cove-control-theme-accent, var(--cove-pixel-cyan)) 52% - ); +.control-center__segments { + display: flex; + margin-inline: 8px; } -.control-center__theme-source { - display: block; - color: var(--cove-text-faint); - font-size: calc(10px * var(--cove-ui-font-scale)); - line-height: 1.5; +.control-center__segments > * { + flex: 1 1 0; } .control-center__theme-status { @@ -268,15 +146,6 @@ line-height: 1.5; } -.control-center__theme-label { - min-width: 0; - color: currentcolor; - font-size: calc(12px * var(--cove-ui-font-scale)); - font-weight: 450; - line-height: 1.35; - overflow-wrap: anywhere; -} - .control-center__footer { padding: 6px 8px; border-top: 1px solid var(--cove-border-subtle); @@ -318,7 +187,6 @@ } .control-center--sheet .control-center__row, -.control-center--sheet .control-center__theme-option, .control-center--sheet .control-center__settings { min-height: 44px; } @@ -330,15 +198,13 @@ @media (pointer: coarse) { .control-center__row, - .control-center__theme-option, .control-center__settings { min-height: 44px; } } @media (prefers-reduced-motion: reduce) { - .control-center__row, - .control-center__theme-option { + .control-center__row { transition-duration: 0.01ms; } } diff --git a/src/contexts/settings/domain/uiThemeChoice.spec.ts b/src/contexts/settings/domain/uiThemeChoice.spec.ts new file mode 100644 index 000000000..c3e44c7d0 --- /dev/null +++ b/src/contexts/settings/domain/uiThemeChoice.spec.ts @@ -0,0 +1,32 @@ +import { describe, expect, it } from 'vitest' +import { describeUiThemeChoice, resolveUiThemeChoice, uiThemeModesFor } from './uiThemeChoice' + +describe('control center theme choice', () => { + it.each([ + ['dark', 'cove', 'dark'], + ['light', 'cove', 'light'], + ['system', 'cove', 'system'], + ['pixel', 'pixel', 'dark'], + ['pixel-light', 'pixel', 'light'], + ['plugin:example:night', null, null], + ] as const)('reads %s as family %s, mode %s', (theme, family, mode) => { + expect(describeUiThemeChoice(theme)).toEqual({ family, mode }) + }) + + it('offers follow system only where the family has it', () => { + expect(uiThemeModesFor('cove')).toEqual(['light', 'dark', 'system']) + expect(uiThemeModesFor('pixel')).toEqual(['light', 'dark']) + }) + + it.each([ + ['cove', 'light', false, 'light'], + ['cove', 'system', true, 'system'], + ['pixel', 'dark', false, 'pixel'], + ['pixel', 'light', true, 'pixel-light'], + // Pixel has no system mode: keep what the system shows now. + ['pixel', 'system', true, 'pixel'], + ['pixel', 'system', false, 'pixel-light'], + ] as const)('%s in %s mode (system dark %s) is %s', (family, mode, prefersDark, expected) => { + expect(resolveUiThemeChoice(family, mode, prefersDark)).toBe(expected) + }) +}) diff --git a/src/contexts/settings/domain/uiThemeChoice.ts b/src/contexts/settings/domain/uiThemeChoice.ts new file mode 100644 index 000000000..7dda185e2 --- /dev/null +++ b/src/contexts/settings/domain/uiThemeChoice.ts @@ -0,0 +1,51 @@ +import { + UI_THEME_DESCRIPTORS, + UI_THEMES, + type BuiltinUiTheme, + type UiTheme, + type UiThemeFamily, +} from './uiSettings' + +/** The control center picks a theme as a family plus a light/dark/system mode. */ +export type UiThemeMode = 'light' | 'dark' | 'system' + +export function uiThemeModesFor(family: UiThemeFamily): UiThemeMode[] { + return UI_THEMES.some( + theme => + UI_THEME_DESCRIPTORS[theme].family === family && + UI_THEME_DESCRIPTORS[theme].baseScheme === 'system', + ) + ? ['light', 'dark', 'system'] + : ['light', 'dark'] +} + +/** Plugin themes have no family or mode here; they are chosen in Settings. */ +export function describeUiThemeChoice(theme: UiTheme): { + family: UiThemeFamily | null + mode: UiThemeMode | null +} { + const descriptor = UI_THEME_DESCRIPTORS[theme as BuiltinUiTheme] + return descriptor + ? { family: descriptor.family, mode: descriptor.baseScheme } + : { family: null, mode: null } +} + +/** A family without a system mode keeps whatever the system shows now. */ +export function resolveUiThemeChoice( + family: UiThemeFamily, + mode: UiThemeMode, + prefersDark: boolean, +): BuiltinUiTheme { + const effectiveMode = + mode === 'system' && !uiThemeModesFor(family).includes('system') + ? prefersDark + ? 'dark' + : 'light' + : mode + const match = UI_THEMES.find( + theme => + UI_THEME_DESCRIPTORS[theme].family === family && + UI_THEME_DESCRIPTORS[theme].baseScheme === effectiveMode, + ) + return match ?? UI_THEMES[0] +} diff --git a/tests/e2e-web-canvas/workerWebCanvas.themes.spec.ts b/tests/e2e-web-canvas/workerWebCanvas.themes.spec.ts index ca4bf896d..c60e9b1bc 100644 --- a/tests/e2e-web-canvas/workerWebCanvas.themes.spec.ts +++ b/tests/e2e-web-canvas/workerWebCanvas.themes.spec.ts @@ -33,13 +33,11 @@ async function selectTheme(page: Page, id: string) { await page.getByTestId('settings-panel-close').click() } -async function selectControlCenterTheme(page: Page, id: string, title: string) { +/** Plugin themes are chosen in Settings; the control center names the active one. */ +async function selectPluginTheme(page: Page, id: string, title: string) { + await selectTheme(page, id) await page.getByTestId('app-header-control-center').click() - const choice = page.getByTestId(`control-center-theme-${id}`) - await choice.check() - await expect(choice).toBeChecked() await expect(page.getByTestId('control-center-current-theme')).toHaveText(title) - await expect(page.locator('html')).toHaveAttribute('data-cove-theme-id', id) await page.getByTestId('control-center-close').click() } @@ -128,7 +126,7 @@ test('remote theme assets, typography and iframe context survive switches, recov const context = observer.locator('#context') await expect(context).toContainText('"themeId":"pixel"') await observer.locator('#draft').fill('Keep my remote view alive') - await selectControlCenterTheme(page, selectedTheme, 'Studio') + await selectPluginTheme(page, selectedTheme, 'Studio') await expect(page.locator('html')).toHaveAttribute('data-cove-theme-base', 'pixel') const note = page.locator('.react-flow__node[data-id="theme-note"]') @@ -189,13 +187,11 @@ test('remote theme assets, typography and iframe context survive switches, recov await expect(context).toContainText('"fonts":[]') await expect(observer.locator('#draft')).toHaveValue('Keep my remote view alive') - await selectControlCenterTheme(page, selectedTheme, 'Studio') + await selectPluginTheme(page, selectedTheme, 'Studio') await page.reload({ waitUntil: 'domcontentloaded' }) await expect(page.locator('html')).toHaveAttribute('data-cove-theme-id', selectedTheme) await expect(header).toHaveCSS('height', '44px') await page.getByTestId('app-header-control-center').click() - const selectedChoice = page.getByTestId(`control-center-theme-${selectedTheme}`) - await expect(selectedChoice).toBeChecked() await expect(page.getByTestId('control-center-current-theme')).toHaveText('Studio') await invokeValue(page.request, 'command', 'plugin.setEnabled', { pluginId: themePluginId, @@ -205,25 +201,25 @@ test('remote theme assets, typography and iframe context survive switches, recov await expect(page.locator('html')).toHaveAttribute('data-cove-theme-id', 'dark') await expect(page.locator('style[data-cove-theme-stylesheet]')).toHaveCount(0) await expect(header).toHaveCSS('height', '34px') - await expect( - page.locator(`[data-testid^="control-center-theme-plugin:${themePluginId}:"]`), - ).toHaveCount(0) + await expect(page.getByTestId('control-center-current-theme')).toHaveCount(0) + await expect(page.getByTestId('control-center').getByRole('status')).toContainText( + 'unavailable', + ) await invokeValue(page.request, 'command', 'plugin.setEnabled', { pluginId: themePluginId, enabled: true, }) await expect(page.locator('html')).toHaveAttribute('data-cove-theme-id', selectedTheme) await expect(context).toContainText(`"themeId":"${selectedTheme}"`) - await expect(selectedChoice).toBeChecked() await expect(page.getByTestId('control-center-current-theme')).toHaveText('Studio') await page.getByTestId('control-center-close').click() await page.setViewportSize({ width: 390, height: 560 }) await page.getByTestId('app-header-control-center').tap() await expectFits(page.getByTestId('control-center')) - await page.getByTestId(`control-center-theme-${themeIdentity('light-copy')}`).check() - await expect(page.getByTestId('control-center-current-theme')).toHaveText('Pixel light copy') - await expect(page.locator('html')).toHaveAttribute('data-cove-theme-base', 'pixel-light') + await page.getByTestId('control-center-theme-family-pixel').check() + await page.getByTestId('control-center-theme-mode-light').check() + await expect(page.locator('html')).toHaveAttribute('data-cove-theme-id', 'pixel-light') await info.attach('web-theme-narrow-control-center', { body: await page.screenshot(), contentType: 'image/png', diff --git a/tests/e2e/command-center.spec.ts b/tests/e2e/command-center.spec.ts index aac2492cb..cd7506ca6 100644 --- a/tests/e2e/command-center.spec.ts +++ b/tests/e2e/command-center.spec.ts @@ -137,7 +137,8 @@ test.describe('Command Center', () => { await expect(window.getByTestId('workspace-search-input')).toBeFocused() await window.getByTestId('workspace-search-close').click() await window.getByTestId('app-header-control-center').click() - await window.getByTestId('control-center-theme-pixel-light').check() + await window.getByTestId('control-center-theme-family-pixel').check() + await window.getByTestId('control-center-theme-mode-light').check() await window.keyboard.press('Escape') await trigger.click() await expect(window.locator('html')).toHaveAttribute('data-cove-theme-id', 'pixel-light') diff --git a/tests/e2e/control-center.spec.ts b/tests/e2e/control-center.spec.ts index 70b198732..d8f32bbc2 100644 --- a/tests/e2e/control-center.spec.ts +++ b/tests/e2e/control-center.spec.ts @@ -54,10 +54,17 @@ test.describe('Control Center', () => { const title = element.querySelector('.control-center__title') const rowLabel = element.querySelector('.control-center__row-label') const rowIcon = element.querySelector('.control-center__row-icon') - const themeList = element.querySelector('.control-center__themes') + const segments = element.querySelectorAll('.control-center__segments') const toggleTrack = element.querySelector('.cove-toggle__track') const toggleThumb = element.querySelector('.cove-toggle__thumb') - if (!title || !rowLabel || !rowIcon || !themeList || !toggleTrack || !toggleThumb) { + if ( + !title || + !rowLabel || + !rowIcon || + segments.length !== 2 || + !toggleTrack || + !toggleThumb + ) { throw new Error('Control Center visual primitives are missing') } @@ -69,7 +76,7 @@ test.describe('Control Center', () => { rowLabelFontSize: Number.parseFloat(getComputedStyle(rowLabel).fontSize), rowIconBackground: getComputedStyle(rowIcon).backgroundColor, rowStatusCount: element.querySelectorAll('.control-center__row-status').length, - themeColumns: getComputedStyle(themeList).gridTemplateColumns.split(' ').length, + segmentWidths: Array.from(segments, segment => segment.getBoundingClientRect().width), radius: getComputedStyle(element).borderTopLeftRadius, overflow: element.scrollWidth > element.clientWidth, toggleTrack: getComputedStyle(toggleTrack).backgroundColor, @@ -77,12 +84,13 @@ test.describe('Control Center', () => { } }) expect(darkVisuals.width).toBeCloseTo(360, 0) - expect(darkVisuals.height).toBeGreaterThanOrEqual(340) + expect(darkVisuals.height).toBeGreaterThanOrEqual(260) expect(darkVisuals.height).toBeLessThanOrEqual(480) expect(darkVisuals.titleFontSize).toBeCloseTo(darkVisuals.rowLabelFontSize, 1) expect(darkVisuals.rowIconBackground).toBe('rgba(0, 0, 0, 0)') expect(darkVisuals.rowStatusCount).toBe(0) - expect(darkVisuals.themeColumns).toBe(2) + // Family and mode controls span the panel's width. + expect(darkVisuals.segmentWidths.every(width => width > darkVisuals.width - 40)).toBe(true) expect(darkVisuals.radius).toBe('8px') expect(darkVisuals.overflow).toBe(false) expect(darkVisuals.toggleTrack).not.toBe(darkVisuals.toggleThumb) @@ -94,12 +102,12 @@ test.describe('Control Center', () => { contentType: 'image/png', }) - await window.locator('[data-testid="control-center-theme-pixel-light"]').click() + await window.locator('[data-testid="control-center-theme-mode-light"]').check() await expect(window.locator('html')).toHaveAttribute('data-cove-theme', 'light') - await expect(window.locator('[data-testid="control-center-theme-pixel-light"]')).toBeChecked() - await expect(window.locator('[data-testid="control-center-current-theme"]')).toContainText( - 'Pixel Light', - ) + await expect(window.locator('html')).toHaveAttribute('data-cove-theme-id', 'pixel-light') + await expect( + window.locator('[data-testid="control-center-theme-family-pixel"]'), + ).toBeChecked() await waitForVisualState(controlCenter) const lightToggleTrack = await controlCenter .locator('.cove-toggle__track') @@ -197,10 +205,8 @@ test.describe('Control Center', () => { const themeInputs = controlCenter.getByRole('radio') await expect(themeInputs).toHaveCount(5) - await expect(window.locator('[data-testid="control-center-theme-dark"]')).toBeChecked() - await expect(window.locator('[data-testid="control-center-current-theme"]')).toContainText( - 'Cove', - ) + await expect(window.locator('[data-testid="control-center-theme-family-cove"]')).toBeChecked() + await expect(window.locator('[data-testid="control-center-theme-mode-dark"]')).toBeChecked() await expect(window.locator('html')).toHaveAttribute('data-cove-family', 'cove') await expect(window.locator('[data-testid="control-center-toggle-sidebar"]')).toHaveCount(0) @@ -252,7 +258,8 @@ test.describe('Control Center', () => { await window.locator('[data-testid="app-header-control-center"]').click() const controlCenter = window.locator('[data-testid="control-center"]') await expect(controlCenter).toBeVisible() - await expect(controlCenter.getByRole('radio')).toHaveCount(5) + // Two families plus Pixel's two modes (it has no follow-system mode). + await expect(controlCenter.getByRole('radio')).toHaveCount(4) await expect(controlCenter).toHaveAttribute('aria-modal', 'true') await expect(window.locator('.app-shell')).toHaveJSProperty('inert', true) await waitForVisualState(controlCenter) diff --git a/tests/e2e/workspace-canvas.agent-standby-banner-context.spec.ts b/tests/e2e/workspace-canvas.agent-standby-banner-context.spec.ts index 55d9c9302..d6cd5c406 100644 --- a/tests/e2e/workspace-canvas.agent-standby-banner-context.spec.ts +++ b/tests/e2e/workspace-canvas.agent-standby-banner-context.spec.ts @@ -156,9 +156,14 @@ test.describe('Workspace Canvas - Agent Standby Banner Context', () => { 0, ) - await window.locator('[data-testid="app-header-control-center"]').click() - await expect(window.locator('[data-testid="control-center"]')).toBeVisible() - await window.locator('[data-testid="control-center-toggle-agent-standby-banner"]').click() + // The notification banner switch lives in Settings. + await window.locator('[data-testid="app-header-settings"]').click() + await window.locator('[data-testid="settings-section-nav-general"]').click() + await window + .locator('.settings-panel') + .getByRole('switch', { name: 'Notification banner' }) + .click() + await window.locator('.settings-panel__close').click() await expect(notificationList).toHaveCount(0) } finally { diff --git a/tests/e2e/workspace-canvas.pixel-light.spec.ts b/tests/e2e/workspace-canvas.pixel-light.spec.ts index 50bc5156c..eded16d4d 100644 --- a/tests/e2e/workspace-canvas.pixel-light.spec.ts +++ b/tests/e2e/workspace-canvas.pixel-light.spec.ts @@ -304,9 +304,9 @@ test.describe('Workspace Canvas - Pixel Light', () => { await setPixelEffect(page, 'canvasPixelBackgroundEnabled', false) await page.locator('.settings-panel__close').click() await page.getByTestId('app-header-control-center').click() - await page.getByTestId('control-center-theme-pixel-light').click() - await expect(page.getByTestId('control-center-theme-pixel-light')).toBeChecked() - await expect(page.getByTestId('control-center-current-theme')).toContainText('Pixel Light') + await page.getByTestId('control-center-theme-mode-light').check() + await expect(page.getByTestId('control-center-theme-family-pixel')).toBeChecked() + await expect(page.locator('html')).toHaveAttribute('data-cove-theme-id', 'pixel-light') await attachIdentityFrame(page, testInfo, 'pixel-light-control-center') await page.keyboard.press('Escape') const mixed = { diff --git a/tests/e2e/workspace-search.spec.ts b/tests/e2e/workspace-search.spec.ts index 6960af9f9..6af3f7a1e 100644 --- a/tests/e2e/workspace-search.spec.ts +++ b/tests/e2e/workspace-search.spec.ts @@ -227,7 +227,8 @@ test.describe('Workspace Search', () => { }) await window.getByTestId('workspace-search-close').click() await window.getByTestId('app-header-control-center').click() - await window.getByTestId('control-center-theme-pixel-light').check() + await window.getByTestId('control-center-theme-family-pixel').check() + await window.getByTestId('control-center-theme-mode-light').check() await window.keyboard.press('Escape') await window.keyboard.press(`${findModifier}+F`) await expect(window.locator('html')).toHaveAttribute('data-cove-theme-id', 'pixel-light') diff --git a/tests/unit/contexts/pixelEffectTheme.spec.ts b/tests/unit/contexts/pixelEffectTheme.spec.ts index d97e0906a..d5d56020c 100644 --- a/tests/unit/contexts/pixelEffectTheme.spec.ts +++ b/tests/unit/contexts/pixelEffectTheme.spec.ts @@ -17,7 +17,7 @@ const themes: UiTheme[] = [...UI_THEMES] beforeAll(() => { const cssFiles = [ 'base.css', - 'control-center.css', + 'settings-panel.appearance-next.css', 'terminal-node.css', 'terminal-node.theme-opencode.css', 'task-node.css', @@ -135,10 +135,14 @@ describe('theme-adaptive pixel effects', () => { it('preserves the Pixel preview identity while other themes adapt their effect colors', () => { selectTheme('pixel') + // The Settings theme gallery shows Pixel's own colours whichever theme is active. + const family = document.createElement('div') + family.className = 'settings-theme-gallery__family' + family.dataset.themeFamily = 'pixel' const preview = document.createElement('span') - preview.className = 'control-center__theme-preview' - preview.dataset.themePreview = 'pixel' - document.body.append(preview) + preview.className = 'settings-theme-gallery__sample' + family.append(preview) + document.body.append(family) const pixelPreview = getComputedStyle(preview).backgroundImage expect(pixelPreview).toContain('linear-gradient') selectTheme('dark') From f91995019f786ee75852d78529cd4ef65bf229b9 Mon Sep 17 00:00:00 2001 From: DeadWaveWave <90459902+DeadWaveWave@users.noreply.github.com> Date: Sat, 17 Oct 2026 13:13:03 +0800 Subject: [PATCH 2/3] docs(ui): document the control center switches Co-Authored-By: Claude Opus 5.5 --- docs/plugins/THEMES.md | 2 +- docs/ui/README.md | 7 ++++--- 2 files changed, 5 insertions(+), 4 deletions(-) diff --git a/docs/plugins/THEMES.md b/docs/plugins/THEMES.md index 394343de7..8a0d7109f 100644 --- a/docs/plugins/THEMES.md +++ b/docs/plugins/THEMES.md @@ -3,7 +3,7 @@ A theme plugin can change OpenCove's visual foundation, materials, typography, component skins and motion. It does not need a backend or a canvas window. Install, update, reload, disable and uninstall it through the same plugin manager -or CLI as any other package. Enabled themes appear in the top-right Control Center and Settings → Appearance. +or CLI as any other package. Enabled themes appear in Settings → Appearance; while one is active, the Control Center shows its name. ## Ownership and scope diff --git a/docs/ui/README.md b/docs/ui/README.md index 5bc5f95b4..132c4cd34 100644 --- a/docs/ui/README.md +++ b/docs/ui/README.md @@ -309,9 +309,10 @@ - 模态界面复用共享 Dialog 的 inert、Tab 约束与焦点恢复。手机打开搜索先聚焦关闭控件, 避免自动唤起软键盘;定位后关闭搜索,返回导航控件,不聚焦终端输入。 弹层交接后旧界面不得通过延迟恢复抢走新界面的焦点。 -- 控制中心开关保留标签与真实 checked 状态,不重复显示开/关状态文字; - 所有主题来自既有主题 registry,选项使用轻量预览与单选语义。控制中心与设置页共用插件主题可用性规则, - 展示已启用且就绪的主题名称与来源;禁用或卸载时隐藏选项,但不改写用户保存的主题选择。 +- 控制中心只放快捷设置:主题族(Cove / Pixel)、明暗(Pixel 没有跟随系统,切到 Pixel 时按系统当前 + 的明暗选)、像素背景、动态信号(新建、关闭、工作中三种像素效果一起开关)和缩略图。开关保留标签与真实 + checked 状态,不重复显示开/关状态文字。主题详情、预览和插件主题在设置 → 外观;当前是插件主题时, + 控制中心显示它的名字,不可用或加载失败时给出提示,但不改写用户保存的主题选择。通知横幅在设置 → 通用。 参考:WAI-ARIA APG Dialog (Modal) 与 Combobox 的焦点约束、返回目标与输入焦点模型。 From 0cdfe27ccbe4528fc7f661e378c7f591a568a46a Mon Sep 17 00:00:00 2001 From: DeadWaveWave <90459902+DeadWaveWave@users.noreply.github.com> Date: Sat, 17 Oct 2026 13:14:04 +0800 Subject: [PATCH 3/3] docs(changelog): note the control center switches Co-Authored-By: Claude Opus 5.5 --- CHANGELOG.md | 1 + 1 file changed, 1 insertion(+) diff --git a/CHANGELOG.md b/CHANGELOG.md index afbd3a49f..0f0561afe 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -22,6 +22,7 @@ The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.0.0/), - Windows: content sits on an inset reading plate below the header, with a fine edge; the document editor reads directly on it. (#453) - Pixel theme: window headers no longer use a live background blur; windows use one tinted frame around the reading plate, which saves GPU work with many windows open. (#454) - Terminals: terminal text can sit directly on the window's reading plate. Settings → Terminal → Terminal Background chooses Auto (transparent on macOS, opaque on Windows and Linux), Transparent or Opaque; changing it keeps the running shell and its output. (#456) +- Control Center: quick switches for theme (Cove or Pixel), light, dark or follow system, pixel background, motion signals and the minimap; plugin themes are chosen in Settings, and "Top-right banner" is now "Notification banner" in Settings. (#457) ### 🐛 Fixed