diff --git a/desktop/public/fonts/material-symbols-outlined.woff2 b/desktop/public/fonts/material-symbols-outlined.woff2 deleted file mode 100644 index 44cb1e96..00000000 Binary files a/desktop/public/fonts/material-symbols-outlined.woff2 and /dev/null differ diff --git a/desktop/src/__tests__/agentsSettings.test.tsx b/desktop/src/__tests__/agentsSettings.test.tsx index 0f652db3..cd43649f 100644 --- a/desktop/src/__tests__/agentsSettings.test.tsx +++ b/desktop/src/__tests__/agentsSettings.test.tsx @@ -136,8 +136,10 @@ const MOCK_SKILL_DETAIL = { skillRoot: '/tmp/skill-docs', } +// By role, not text: the pane's page title is the nav label verbatim, so once +// the Agents pane is open "Agents" names both the rail entry and the heading. function switchToAgentsTab() { - fireEvent.click(screen.getByText('Agents')) + fireEvent.click(screen.getByRole('button', { name: 'Agents' })) } function switchToSkillsTab() { @@ -192,7 +194,15 @@ describe('Settings > Agents tab', () => { it('renders the Agents tab button in sidebar', () => { render() - expect(screen.getByText('Agents')).toBeInTheDocument() + expect(screen.getByRole('button', { name: 'Agents' })).toBeInTheDocument() + }) + + it('titles the pane with the nav label', () => { + render() + switchToAgentsTab() + + expect(screen.getByRole('heading', { level: 2, name: 'Agents' })).toBeInTheDocument() + expect(screen.getByRole('button', { name: 'Create Agent' })).toBeInTheDocument() }) it('shows loading spinner when fetching agents', () => { @@ -243,12 +253,14 @@ describe('Settings > Agents tab', () => { render() switchToAgentsTab() - expect(screen.getByText('Browse installed agents')).toBeInTheDocument() - expect(screen.getByText('Agent Browser')).toBeInTheDocument() - expect(screen.getAllByText('User').length).toBeGreaterThan(0) - expect(screen.getAllByText('Built-in').length).toBeGreaterThan(0) - expect(screen.getAllByText('Project').length).toBeGreaterThan(0) - expect(screen.getAllByText('Plugin').length).toBeGreaterThan(0) + // Summary tiles, then one labelled section per source, in source order. + expect(screen.getByText('Total agents')).toBeInTheDocument() + expect(screen.getByText('Active agents')).toBeInTheDocument() + const sourceHeadings = ['User', 'Project', 'Plugin', 'Built-in'] + .map((name) => screen.getByRole('heading', { level: 3, name: new RegExp(`^${name}\\s*\\d+$`) })) + for (const [index, heading] of sourceHeadings.slice(0, -1).entries()) { + expect(heading.compareDocumentPosition(sourceHeadings[index + 1]!) & Node.DOCUMENT_POSITION_FOLLOWING).toBeTruthy() + } expect(screen.getByText('code-reviewer')).toBeInTheDocument() expect(screen.getByText('Writes technical documentation')).toBeInTheDocument() expect(screen.getByText('telegram:pairing')).toBeInTheDocument() @@ -334,7 +346,9 @@ describe('Settings > Agents tab', () => { await Promise.resolve() }) - expect(screen.getByText('Installed Plugins')).toBeInTheDocument() + // The Plugins pane is titled with its nav label, which the rail entry also + // carries — the heading is what proves the pane itself opened. + expect(screen.getByRole('heading', { name: 'Plugins' })).toBeInTheDocument() }) }) diff --git a/desktop/src/__tests__/diagnosticsSettings.test.tsx b/desktop/src/__tests__/diagnosticsSettings.test.tsx index 644d81ec..fe6e1440 100644 --- a/desktop/src/__tests__/diagnosticsSettings.test.tsx +++ b/desktop/src/__tests__/diagnosticsSettings.test.tsx @@ -279,8 +279,17 @@ describe('Settings > Diagnostics tab', () => { expect(screen.getByText('event-1')).toBeInTheDocument() expect(screen.getByText(/best-effort/i)).toHaveTextContent(/review.*private metadata/i) - const eventRow = screen.getByText('cli_start_failed').closest('.grid') - expect(eventRow).toHaveClass('grid-cols-1', 'md:grid-cols-[120px_92px_1fr]') + // Each event is one list row: type, severity, timestamp, session, summary and + // its copyable ID all stay together, so a long summary cannot push the + // timestamp onto a different event. + const eventRow = screen.getByText('cli_start_failed').closest('[data-testid="diagnostic-event"]') as HTMLElement + expect(eventRow).not.toBeNull() + expect(within(eventRow).getByText('error')).toBeInTheDocument() + expect(within(eventRow).getByText('session-1')).toBeInTheDocument() + expect(within(eventRow).getByText('CLI exited during startup with code 1')).toBeInTheDocument() + expect(within(eventRow).getByText(new Date('2026-05-02T00:00:00.000Z').toLocaleString())).toBeInTheDocument() + expect(within(eventRow).getByRole('button', { name: 'Copy event ID: event-1' })).toBeInTheDocument() + expect(screen.getAllByTestId('diagnostic-event')).toHaveLength(100) }) it('shows local-index state, counts, storage, update time, and error code without rollout modes', async () => { @@ -572,7 +581,9 @@ describe('Settings > Diagnostics tab', () => { await screen.findByText('Log directory') expect(diagnosticsTab).toHaveAttribute('aria-current', 'page') - expect(diagnosticsTab.querySelector('.material-symbols-outlined')).toHaveAttribute('aria-hidden', 'true') + // The nav icon is decorative: whichever icon element the rail renders first + // must be hidden so the tab is announced by its label alone. + expect(diagnosticsTab.firstElementChild).toHaveAttribute('aria-hidden', 'true') }) it('exports a diagnostics bundle from the settings page', async () => { diff --git a/desktop/src/__tests__/generalSettings.test.tsx b/desktop/src/__tests__/generalSettings.test.tsx index db7c49ab..a4ea8c69 100644 --- a/desktop/src/__tests__/generalSettings.test.tsx +++ b/desktop/src/__tests__/generalSettings.test.tsx @@ -1,5 +1,5 @@ import { beforeEach, describe, expect, it, vi } from 'vitest' -import { act, fireEvent, render, screen, waitFor, within } from '@testing-library/react' +import { act, cleanup, fireEvent, render, screen, waitFor, within } from '@testing-library/react' import '@testing-library/jest-dom' import { DesktopSettings as Settings } from '../pages/Settings' @@ -475,16 +475,27 @@ describe('Settings > General tab', () => { const activeItem = screen.getByRole('button', { name: 'General' }) // Selection is a rounded ground inside the rail, not a full-bleed band: // the rail is padded, so a square highlight would touch the divider. - expect(activeItem.className).toContain('rounded-[var(--radius-md)]') - expect(activeItem.className).toContain('bg-[var(--color-surface-hover)]') + expect(activeItem.className).toContain('rounded-[var(--radius-sm)]') + expect(activeItem.className).toContain('bg-[var(--color-surface-selected)]') + expect(activeItem.className).toContain('h-[30px]') expect(activeItem).toHaveAttribute('aria-current', 'page') - const rail = activeItem.parentElement?.parentElement - expect(rail?.className).toContain('w-[195px]') - expect(rail?.className).toContain('pl-3') - expect(rail?.className).toContain('pr-1') - expect(activeItem.className).toContain('pl-3') - expect(activeItem.className).toContain('pr-2') + const rail = screen.getByTestId('settings-navigation') + expect(rail).toContainElement(activeItem) + expect(rail.className).toContain('w-[216px]') + expect(rail.className).toContain('px-2.5') + // The rail opens with its own small title, and "About" sits apart below a rule. + expect(within(rail).getByText('Settings')).toBeInTheDocument() + const about = within(rail).getByRole('button', { name: 'About' }) + const diagnostics = within(rail).getByRole('button', { name: 'Diagnostics' }) + expect((diagnostics.compareDocumentPosition(about) & Node.DOCUMENT_POSITION_FOLLOWING) !== 0).toBe(true) + }) + + it('titles each pane with its rail label', () => { + useUIStore.setState({ activeSettingsTab: 'general', pendingSettingsTab: null }) + render() + + expect(screen.getByRole('heading', { level: 2, name: 'General' })).toBeInTheDocument() }) it('marks the pure white appearance theme as selected', () => { @@ -539,6 +550,21 @@ describe('Settings > General tab', () => { expect(screen.getByRole('button', { name: 'Ink Night' })).toHaveAttribute('aria-pressed', 'false') }) + it('keeps the theme swatches at preview size on a wide settings frame', () => { + // The settings frame is fluid up to 1120px; the swatch grid must not grow + // with it, in either picker layout. + render() + fireEvent.click(screen.getByText('General')) + expect(screen.getByRole('button', { name: 'Pure White' }).parentElement).toHaveClass('grid-cols-3', 'max-w-[600px]') + cleanup() + + useUIStore.setState({ followSystemTheme: true, lightTheme: 'celadon', darkTheme: 'ink-blue', theme: 'ink-blue', activeSettingsTab: 'general' }) + render() + for (const label of ['Pure White', 'Ink Night']) { + expect(screen.getByRole('button', { name: label }).parentElement, label).toHaveClass('grid-cols-4', 'max-w-[600px]') + } + }) + it('hides the light-half hint when not following the system', () => { render() @@ -572,41 +598,70 @@ describe('Settings > General tab', () => { expect(screen.getByRole('button', { name: 'Pure White' })).toHaveAttribute('aria-pressed', 'false') }) - it('keeps UI zoom below system notifications because it is a secondary setting', () => { + it('groups General settings into topic sections in a stable order', () => { render() fireEvent.click(screen.getByText('General')) - const notificationsHeading = screen.getByRole('heading', { name: 'System Notifications' }) - const uiZoomHeading = screen.getByRole('heading', { name: 'UI Zoom' }) - const networkHeading = screen.getByRole('heading', { name: 'Network' }) - const webFetchHeading = screen.getByRole('heading', { name: 'WebFetch Preflight' }) + // The stand-alone toggles are grouped by topic: what the app looks like + // first, then what a new session starts from, then plumbing and data. + const order = [ + 'Appearance', + 'Language', + 'Chat appearance', + 'UI Zoom', + 'Agent preferences', + 'System Notifications', + 'Network', + 'WebSearch', + 'Agent trace', + 'Session History', + 'Data Storage Location', + ].map((name) => screen.getByRole('heading', { level: 3, name })) + for (const [index, heading] of order.slice(0, -1).entries()) { + expect( + (heading.compareDocumentPosition(order[index + 1]!) & Node.DOCUMENT_POSITION_FOLLOWING) !== 0, + `${order[index + 1]!.textContent} should follow ${heading.textContent}`, + ).toBe(true) + } - expect((notificationsHeading.compareDocumentPosition(uiZoomHeading) & Node.DOCUMENT_POSITION_FOLLOWING) !== 0).toBe(true) - expect((uiZoomHeading.compareDocumentPosition(networkHeading) & Node.DOCUMENT_POSITION_FOLLOWING) !== 0).toBe(true) - expect((networkHeading.compareDocumentPosition(webFetchHeading) & Node.DOCUMENT_POSITION_FOLLOWING) !== 0).toBe(true) + // Each Agent switch lives in the shared Agent preferences card rather + // than under a heading of its own. + const agentSection = screen.getByRole('heading', { name: 'Agent preferences' }).closest('section')! + for (const name of [ + 'Enable thinking mode', + 'Enable Ultracode keyword trigger', + 'Enable Agent Teams', + 'Enable Auto-dream', + ]) { + expect(within(agentSection).getByRole('switch', { name })).toBeInTheDocument() + } + // WebFetch preflight is a network concern and sits in that card. + const networkSection = screen.getByRole('heading', { name: 'Network' }).closest('section')! + expect(within(networkSection).getByRole('switch', { name: 'Skip WebFetch domain preflight' })).toBeInTheDocument() }) it('lets users choose Ctrl or Command Enter as the chat send shortcut', async () => { render() fireEvent.click(screen.getByText('General')) - fireEvent.click(screen.getByRole('button', { name: /Ctrl\/Cmd\+Enter sends/i })) + fireEvent.click(screen.getByRole('radio', { name: /Ctrl\/Cmd\+Enter sends/i })) await waitFor(() => { expect(useSettingsStore.getState().setChatSendBehavior).toHaveBeenCalledWith('modifierEnter') }) - expect(screen.getByRole('button', { name: /Ctrl\/Cmd\+Enter sends/i })).toHaveAttribute('aria-pressed', 'true') + expect(screen.getByRole('radio', { name: /Ctrl\/Cmd\+Enter sends/i })).toHaveAttribute('aria-checked', 'true') + expect(screen.getByRole('radio', { name: /^Enter sends/i })).toHaveAttribute('aria-checked', 'false') }) it('saves provider network timeout and manual proxy from General settings', async () => { render() fireEvent.click(screen.getByText('General')) - expect(screen.getByRole('button', { name: /Direct connection/i })).toHaveAttribute('aria-pressed', 'true') - expect(screen.getByRole('button', { name: /System proxy/i })).toBeInTheDocument() + expect(screen.getByRole('radio', { name: /Direct connection/i })).toHaveAttribute('aria-checked', 'true') + expect(screen.getByRole('radio', { name: /System proxy/i })).toBeInTheDocument() - fireEvent.click(screen.getByRole('button', { name: /^Manual proxy/i })) + fireEvent.click(screen.getByRole('radio', { name: /^Manual proxy/i })) const proxyInput = screen.getByLabelText('Proxy URL') const saveButton = screen.getAllByRole('button', { name: 'Save' })[0]! @@ -700,7 +755,7 @@ describe('Settings > General tab', () => { expect(input).toHaveValue(365) const section = screen.getByRole('heading', { name: 'Session History' }) - .parentElement as HTMLElement + .closest('section') as HTMLElement const saveButton = within(section).getByRole('button', { name: 'Save' }) expect(saveButton).toBeDisabled() @@ -758,7 +813,7 @@ describe('Settings > General tab', () => { fireEvent.change(input, { target: { value: '0' } }) const section = screen.getByRole('heading', { name: 'Session History' }) - .parentElement as HTMLElement + .closest('section') as HTMLElement await act(async () => { fireEvent.click(within(section).getByRole('button', { name: 'Save' })) }) @@ -792,7 +847,7 @@ describe('Settings > General tab', () => { }) const section = screen.getByRole('heading', { name: 'Session History' }) - .parentElement as HTMLElement + .closest('section') as HTMLElement await act(async () => { fireEvent.click(within(section).getByRole('button', { name: 'Save' })) }) @@ -830,7 +885,7 @@ describe('Settings > General tab', () => { }) const section = screen.getByRole('heading', { name: 'Session History' }) - .parentElement as HTMLElement + .closest('section') as HTMLElement const saveButton = within(section).getByRole('button', { name: 'Save' }) fireEvent.click(saveButton) @@ -871,7 +926,7 @@ describe('Settings > General tab', () => { }) const section = screen.getByRole('heading', { name: 'Session History' }) - .parentElement as HTMLElement + .closest('section') as HTMLElement await act(async () => { fireEvent.click(within(section).getByRole('button', { name: 'Save' })) }) @@ -914,7 +969,7 @@ describe('Settings > General tab', () => { }) const section = screen.getByRole('heading', { name: 'Session History' }) - .parentElement as HTMLElement + .closest('section') as HTMLElement await act(async () => { fireEvent.click(within(section).getByRole('button', { name: 'Save' })) }) @@ -1414,7 +1469,7 @@ describe('Settings > General tab', () => { expect(useSettingsStore.getState().setAutoDreamEnabled).toHaveBeenCalledWith(false) }) - it('keeps General checkbox inputs anchored inside their visible rows', () => { + it('renders every General on/off setting as a named switch beside its visible title', () => { render() fireEvent.click(screen.getByText('General')) @@ -1426,12 +1481,14 @@ describe('Settings > General tab', () => { 'Enable system notifications', 'Skip WebFetch domain preflight', ]) { - const toggle = screen.getByLabelText(label) - const row = toggle.closest('label') as HTMLElement | null - expect(toggle).toHaveClass('settings-checkbox-input') - expect(toggle).not.toHaveClass('sr-only') + // One control per setting, announced as a switch under the same words + // the row shows — the hand-rolled checkbox rows these replaced each + // stretched an invisible input over the whole card. + const toggle = screen.getByRole('switch', { name: label }) + expect(screen.getByLabelText(label)).toBe(toggle) + const row = toggle.closest('.min-h-\\[52px\\]') as HTMLElement | null expect(row).not.toBeNull() - expect(row!).toHaveClass('relative') + expect(within(row!).getByText(label)).toBeVisible() } }) @@ -1559,8 +1616,8 @@ describe('Settings > General tab', () => { fireEvent.click(screen.getByText('General')) expect(screen.queryByRole('region', { name: 'H5 Access' })).not.toBeInTheDocument() - const generalTab = screen.getByText('General') - const h5Tab = screen.getByText('H5 Access') + const generalTab = screen.getByRole('button', { name: 'General' }) + const h5Tab = screen.getByRole('button', { name: 'H5 Access' }) expect((generalTab.compareDocumentPosition(h5Tab) & Node.DOCUMENT_POSITION_FOLLOWING) !== 0).toBe(true) fireEvent.click(h5Tab) @@ -2098,12 +2155,12 @@ describe('Settings > General tab', () => { fireEvent.click(screen.getByText('General')) - fireEvent.click(screen.getByRole('button', { name: 'Tavily' })) + fireEvent.click(screen.getByRole('radio', { name: 'Tavily' })) fireEvent.change(screen.getByLabelText('Tavily API key'), { target: { value: 'tvly-test-key' }, }) const webSearchSection = screen.getByRole('heading', { name: 'WebSearch' }) - .parentElement as HTMLElement + .closest('section') as HTMLElement fireEvent.click(within(webSearchSection).getByRole('button', { name: 'Save' })) expect(useSettingsStore.getState().setWebSearch).toHaveBeenCalledWith({ @@ -2186,11 +2243,15 @@ describe('Settings > Providers tab', () => { render() const card = screen.getByTestId('provider-provider-1') - // 1.5px so the default row reads as chosen at a glance without the heavier - // ring the focus border gave it, which collided with the real focus ring. - expect(card.className).toContain('border-[1.5px]') + // A terracotta-mixed hairline plus a soft brand halo, so the default row + // reads as chosen at a glance — not the focus border, which collided with + // the real focus ring. expect(card.className).toContain('border-[var(--color-primary-fixed-dim)]') + expect(card.className).toContain('shadow-[0_0_0_3px_var(--color-brand-soft)]') expect(card.className).not.toContain('border-[var(--color-border-focus)]') + // Only the default card carries the accent. + const other = screen.getByTestId('claude-official-provider') + expect(other.className).not.toContain('border-[var(--color-primary-fixed-dim)]') }) it('does not query official OAuth status before providers finish loading', () => { @@ -2301,17 +2362,18 @@ describe('Settings > Providers tab', () => { }) it.each([ - ['zh', '模型配置', '模型管理', '添加模型', '配置名称'], - ['zh-TW', '模型設定', '模型管理', '新增模型', '設定名稱'], - ['en', 'Model Settings', 'Model Management', 'Add Model', 'Configuration name'], - ['jp', 'モデル設定', 'モデル管理', 'モデルを追加', '設定名'], - ['kr', '모델 설정', '모델 관리', '모델 추가', '설정 이름'], - ] as const)('uses model terminology throughout settings in %s', (locale, menu, title, add, name) => { + ['zh', '模型配置', '添加模型', '配置名称'], + ['zh-TW', '模型設定', '新增模型', '設定名稱'], + ['en', 'Model Settings', 'Add Model', 'Configuration name'], + ['jp', 'モデル設定', 'モデルを追加', '設定名'], + ['kr', '모델 설정', '모델 추가', '설정 이름'], + ] as const)('uses model terminology throughout settings in %s', (locale, menu, add, name) => { useSettingsStore.setState({ locale }) render() expect(screen.getByRole('button', { name: new RegExp(menu) })).toBeInTheDocument() - expect(screen.getByRole('heading', { name: title })).toBeInTheDocument() + // The pane is titled with the rail label itself, so the two never disagree. + expect(screen.getByRole('heading', { level: 2, name: menu })).toBeInTheDocument() fireEvent.click(screen.getByRole('button', { name: new RegExp(add) })) const dialog = screen.getByRole('dialog') @@ -3697,10 +3759,10 @@ describe('Settings > About tab', () => { render() fireEvent.click(screen.getByRole('button', { name: /Advanced update proxy/i })) - expect(screen.getByRole('button', { name: /System proxy/i })).toHaveAttribute('aria-pressed', 'true') + expect(screen.getByRole('radio', { name: /System proxy/i })).toHaveAttribute('aria-checked', 'true') expect(screen.getByText('This only affects app update checks and downloads.')).toBeInTheDocument() - fireEvent.click(screen.getByRole('button', { name: /Manual proxy/i })) + fireEvent.click(screen.getByRole('radio', { name: /Manual proxy/i })) const proxyInput = screen.getByLabelText('Proxy URL') const saveButton = screen.getByRole('button', { name: 'Save' }) @@ -3731,9 +3793,9 @@ describe('Settings > About tab', () => { render() fireEvent.click(screen.getByRole('button', { name: /Advanced update proxy/i })) - expect(screen.getByRole('button', { name: /Manual proxy/i })).toHaveAttribute('aria-pressed', 'true') + expect(screen.getByRole('radio', { name: /Manual proxy/i })).toHaveAttribute('aria-checked', 'true') - fireEvent.click(screen.getByRole('button', { name: /System proxy/i })) + fireEvent.click(screen.getByRole('radio', { name: /System proxy/i })) const saveButton = screen.getByRole('button', { name: 'Save' }) await act(async () => { diff --git a/desktop/src/__tests__/mcpSettings.test.tsx b/desktop/src/__tests__/mcpSettings.test.tsx index 225de69a..e1c18f3e 100644 --- a/desktop/src/__tests__/mcpSettings.test.tsx +++ b/desktop/src/__tests__/mcpSettings.test.tsx @@ -350,6 +350,52 @@ describe('McpSettings', () => { expect(screen.getByRole('button', { name: 'Save' })).toBeEnabled() }) + it('switches transport from the segmented control on create and locks it while editing', async () => { + const server = { + name: 'global-user', + scope: 'user', + transport: 'http', + enabled: true, + status: 'connected', + statusLabel: 'Connected', + configLocation: '/tmp/config', + summary: 'https://example.com/mcp', + canEdit: true, + canRemove: true, + canReconnect: true, + canToggle: true, + config: { type: 'http', url: 'https://example.com/mcp', headers: {} }, + } as const + useMcpStore.setState({ servers: [server] }) + + await renderLoadedMcpSettings() + + await act(async () => { + fireEvent.click(screen.getByRole('button', { name: /add server/i })) + }) + + const transport = screen.getByRole('radiogroup', { name: 'Transport' }) + expect(screen.getByRole('radio', { name: 'STDIO' })).toHaveAttribute('aria-checked', 'true') + expect(screen.getByLabelText(/Command to launch/)).toBeInTheDocument() + + fireEvent.click(screen.getByRole('radio', { name: 'Streamable HTTP' })) + expect(screen.getByRole('radio', { name: 'Streamable HTTP' })).toHaveAttribute('aria-checked', 'true') + expect(screen.queryByLabelText(/Command to launch/)).not.toBeInTheDocument() + expect(screen.getByLabelText(/^URL/)).toBeInTheDocument() + expect(transport).not.toHaveTextContent(/expand_more|check/) + + await act(async () => { + fireEvent.click(screen.getByRole('button', { name: /back/i })) + }) + await act(async () => { + fireEvent.click(screen.getByRole('button', { name: 'Open global-user' })) + }) + + for (const radio of screen.getAllByRole('radio')) expect(radio).toBeDisabled() + expect(screen.getByRole('radio', { name: 'Streamable HTTP' })).toHaveAttribute('aria-checked', 'true') + expect(screen.getByText('If you would like to switch MCP server type, uninstall first.')).toBeInTheDocument() + }) + it('keeps same-name project MCP servers distinct by project path', async () => { useMcpStore.setState({ servers: [ diff --git a/desktop/src/__tests__/pages.test.tsx b/desktop/src/__tests__/pages.test.tsx index 0a328ca5..31d088f1 100644 --- a/desktop/src/__tests__/pages.test.tsx +++ b/desktop/src/__tests__/pages.test.tsx @@ -185,7 +185,8 @@ describe('Content-only pages render without errors', () => { await Promise.resolve() }) expect(container.querySelector('[data-composer-editor]')).toBeInTheDocument() - expect(container.innerHTML).toContain('New session') + // The 「素」 new-session hero asks what to work on (with or without a project). + expect(container.innerHTML).toMatch(/What should we work on/) expect(container.innerHTML).toContain('Ask anything') }) @@ -274,8 +275,8 @@ describe('Content-only pages render without errors', () => { }, }) const { container } = render() - // With empty messages, the hero is shown - expect(container.innerHTML).toContain('New session') + // With empty messages, the new-session hero is shown + expect(container.innerHTML).toMatch(/What should we work on/) // ChatInput renders the ProseMirror composer const composer = container.querySelector('[data-composer-editor]') expect(composer).toBeInTheDocument() @@ -693,7 +694,11 @@ describe('Content-only pages render without errors', () => { expect(sendMessage).not.toHaveBeenCalled() expect(await screen.findByText('Session inspector')).toBeInTheDocument() expect(vi.mocked(sessionsApi.getInspection)).toHaveBeenCalledWith(SESSION_ID, { includeContext: false }) - expect(container.innerHTML).toContain('bg-[var(--color-inspector-surface)]') + // The inspector is a token-colored popover card, not the old hex-tinted sheet. + expect(screen.getByText('Session inspector').closest('.absolute')).toHaveClass( + 'bg-[var(--color-surface-container-lowest)]', + 'shadow-[var(--shadow-dropdown)]', + ) expect(container.innerHTML).not.toContain('bg-[#fbfaf6]') expect(container.innerHTML).not.toContain('bg-[#f4f2ed]') expect(container.innerHTML).not.toContain('border-[#d8b3a8]') @@ -835,7 +840,9 @@ describe('Content-only pages render without errors', () => { const indicator = await screen.findByLabelText('Context usage loading') expect(indicator).toHaveTextContent('--') - expect(indicator).toHaveClass('h-8') + // Same 28px box as the loaded indicator, so the toolbar does not shift when + // the first context reading arrives. + expect(indicator).toHaveClass('h-7') resetPageStores() }) @@ -1329,12 +1336,12 @@ describe('AppShell layout renders chrome', () => { }) describe('Design system compliance', () => { - it('Pages use Material Symbols Outlined icons', () => { + it('Pages draw their icons from lucide, never Material Symbols ligatures', () => { const pages = [EmptySession] for (const Page of pages) { const { container, unmount } = render() - const icons = container.querySelectorAll('.material-symbols-outlined') - expect(icons.length).toBeGreaterThan(0) + expect(container.querySelectorAll('svg.lucide').length).toBeGreaterThan(0) + expect(container.querySelector('.material-symbols-outlined')).toBeNull() unmount() } }) diff --git a/desktop/src/__tests__/pluginsSettings.test.tsx b/desktop/src/__tests__/pluginsSettings.test.tsx index 8c2cd8e3..828a40c6 100644 --- a/desktop/src/__tests__/pluginsSettings.test.tsx +++ b/desktop/src/__tests__/pluginsSettings.test.tsx @@ -243,8 +243,10 @@ describe('Settings > Plugins tab', () => { render() switchToPluginsTab() - expect(screen.getByText('Browse installed plugins')).toBeInTheDocument() - expect(screen.getByText('Plugin Manager')).toBeInTheDocument() + // The settings page head names the page; the list opens on its numbers + // instead of repeating a second title block under it. + expect(screen.queryByText('Browse installed plugins')).not.toBeInTheDocument() + expect(screen.getByText('Total plugins')).toBeInTheDocument() expect(screen.getAllByText('Needs attention').length).toBeGreaterThan(0) expect(screen.getByText('github')).toBeInTheDocument() expect(screen.getByText('Python language tooling')).toBeInTheDocument() diff --git a/desktop/src/__tests__/skillsSettings.test.tsx b/desktop/src/__tests__/skillsSettings.test.tsx index 7af302e7..6ee57554 100644 --- a/desktop/src/__tests__/skillsSettings.test.tsx +++ b/desktop/src/__tests__/skillsSettings.test.tsx @@ -133,8 +133,10 @@ describe('Settings > Skills tab', () => { render() switchToSkillsTab() - expect(screen.getByText('Browse installed skills')).toBeInTheDocument() - expect(screen.getByText('Skill Browser')).toBeInTheDocument() + // The settings page head names the browser; the list opens on search and + // its numbers rather than a second title block. + expect(screen.queryByText('Browse installed skills')).not.toBeInTheDocument() + expect(screen.getByRole('searchbox', { name: 'Search skills' })).toBeInTheDocument() expect(screen.getByText('Total skills')).toBeInTheDocument() expect(screen.getByText('Alpha Skill')).toBeInTheDocument() expect(screen.getByText('Second skill description')).toBeInTheDocument() @@ -299,6 +301,8 @@ describe('Settings > Skills tab', () => { await Promise.resolve() }) - expect(screen.getByText('Installed Plugins')).toBeInTheDocument() + // The Plugins pane is titled with its rail label, which the rail entry also + // carries — the heading is what proves the pane itself opened. + expect(screen.getByRole('heading', { name: 'Plugins' })).toBeInTheDocument() }) }) diff --git a/desktop/src/__tests__/untranslatedText.test.ts b/desktop/src/__tests__/untranslatedText.test.ts index ba5f8751..96b99694 100644 --- a/desktop/src/__tests__/untranslatedText.test.ts +++ b/desktop/src/__tests__/untranslatedText.test.ts @@ -41,7 +41,8 @@ const NON_PROSE_ELEMENTS = new Set(['kbd', 'Keycap', 'code', 'pre', 'style', 'sc const LANGUAGE_NEUTRAL: Record = { 'components/agentTeams/AgentTeamsCanvas.tsx': ['zZ'], // a sleeping agent's glyph 'components/agentTeams/AgentTeamsWorkbench.tsx': ['Agent Teams ·'], // product name, kept in every locale - 'components/chat/MermaidRenderer.tsx': ['Mermaid'], // the diagram language + 'components/chat/MermaidRenderer.tsx': ['mermaid'], // the diagram language, as a code block labels it + 'components/chat/ToolCallBlock.tsx': ['bash', 'powershell'], // shell names, as a code block labels them 'components/layout/H5ConnectionView.tsx': ['https://chat.example.com'], // example URL 'components/layout/Sidebar.tsx': ['cc-', 'haha', 'GitHub'], // wordmark, brand 'components/market/FrontmatterPanel.tsx': ['true', 'false'], // YAML values, shown as written @@ -52,8 +53,8 @@ const LANGUAGE_NEUTRAL: Record = { 'components/workspace/WorkspaceDiffSurface.tsx': ['diff --git'], // the git header, read to screen readers 'features/pets/PetSettings.tsx': ['px', 'moon-cat'], // unit, example ID 'pages/Connectors.tsx': ['MCP', 'CLI'], // transport names - 'pages/McpSettings.tsx': ['STDIO'], // transport name - 'pages/MemorySettings.tsx': ['MARKDOWN'], // file format + 'components/search/GlobalSearchModal.tsx': ['esc'], // the key, as printed on its keycap + 'pages/MemorySettings.tsx': ['Markdown'], // file format 'pages/settings/AboutSettings.tsx': [ // product, repository and author; example proxy 'Claude Code Haha', 'GitHub', 'NanmiCoder/cc-haha', '程序员阿江-Relakkes', 'http://127.0.0.1:7890', ], diff --git a/desktop/src/components/AGENTS.md b/desktop/src/components/AGENTS.md index b6232869..aeca706f 100644 --- a/desktop/src/components/AGENTS.md +++ b/desktop/src/components/AGENTS.md @@ -154,6 +154,18 @@ style={{ zIndex: 'var(--z-dialog)' }} > > **`SearchField` 的 `clearLabel`** — 组件内部一度用 `` `Clear ${label}` `` 拼英文,导致任何已经翻译过清除按钮的调用方采用它反而是 i18n 退化。**组件库自己不许拼用户可见的英文。** +### 3.8 「素 Porcelain」视觉约束 + +2026-10 的全量改版把界面收敛到一套可检查的约束,`src/theme/designSystem.test.ts` 逐条守住(违规会列出 文件:行): + +- **字号只用** 11 / 12 / 13 / 14 / 15 / 18 / 22 / 26(`text-xs`、`text-sm`、`text-lg` 可用;`text-base/xl/2xl…` 与小数字号不可用)。 +- **字重最高 semibold**:Inter 只自托管到 600,`font-bold` 会让浏览器合成糊掉的粗体;`font-mono` 不配 semibold(JetBrains Mono 只有 400)。 +- **中文不用斜体、不用宽字距大写标签**;摘要列表用「、」。 +- **图标只用 lucide**(`strokeWidth={1.75}`);`IconButton` 的 `icon` 只接受元素,不再接受 Material 字符串。 +- **圆角只用 token**:`--radius-xs` 4 / `sm` 6 / `md` 8 / `lg` 12 / `xl` 16 / `3xl` 20 / `full`。 +- **状态色语义固定**:运行中=info、等你确认=warning、完成=success、失败=error;陶土色只给品牌、发送键与选中指示。 +- 设置页统一用 `components/settings/SettingsSection.tsx` 的骨架(PageHeader → Section → Group → Row)。 + --- ## 四、桌面端硬约束 diff --git a/desktop/src/components/ErrorBoundary.tsx b/desktop/src/components/ErrorBoundary.tsx index abec65de..32e82a72 100644 --- a/desktop/src/components/ErrorBoundary.tsx +++ b/desktop/src/components/ErrorBoundary.tsx @@ -36,7 +36,7 @@ function ErrorBoundaryFallback() { return (
-
{t('errorBoundary.title')}
+
{t('errorBoundary.title')}
{t('errorBoundary.description')}
diff --git a/desktop/src/components/activity/AgentMascot.tsx b/desktop/src/components/activity/AgentMascot.tsx index d3ac3d99..c660c56f 100644 --- a/desktop/src/components/activity/AgentMascot.tsx +++ b/desktop/src/components/activity/AgentMascot.tsx @@ -85,7 +85,8 @@ function ringClassName(tone: AgentMascotSpec['tone']): string { if (tone === 'danger') return 'border-[color-mix(in_srgb,var(--color-error)_42%,transparent)]' if (tone === 'success') return 'border-[color-mix(in_srgb,var(--color-success)_34%,transparent)]' if (tone === 'muted') return 'border-[color-mix(in_srgb,var(--color-text-tertiary)_24%,transparent)]' - return 'border-[color-mix(in_srgb,var(--color-brand)_42%,transparent)]' + // Running is information, not brand: the app reads blue as "in progress". + return 'border-[color-mix(in_srgb,var(--color-info)_42%,transparent)]' } export function AgentMascot({ seed, status }: { seed: string; status: ActivityStatus }) { @@ -108,7 +109,7 @@ export function AgentMascot({ seed, status }: { seed: string; status: ActivitySt {isActive ? ( ) diff --git a/desktop/src/components/activity/SessionActivityButton.tsx b/desktop/src/components/activity/SessionActivityButton.tsx index 7ee2c6ba..bd737e44 100644 --- a/desktop/src/components/activity/SessionActivityButton.tsx +++ b/desktop/src/components/activity/SessionActivityButton.tsx @@ -18,17 +18,15 @@ export function SessionActivityButton({ const toggle = useActivityPanelStore((state) => state.toggle) return ( } + icon={} label={resolvedLabel} size="md" - // The design's active toolbar toggle is a terracotta wash with a - // terracotta glyph, which `tone="brand"` + `filled` already are. The - // `className` fill this replaces raced the tone's own `bg-[…]`, and which - // won came down to stylesheet order. - tone={isOpen ? 'brand' : 'muted'} - filled={isOpen} + // An open panel is a pressed toolbar toggle: the neutral selected fill, + // not a terracotta wash — brand is kept for the send key and selection + // marks. `pressed` also owns `aria-pressed`. + tone="muted" + pressed={isOpen} aria-expanded={isOpen} - aria-pressed={isOpen} onClick={() => toggle(sessionId)} data-active={isOpen ? 'true' : 'false'} data-session-activity-trigger="true" diff --git a/desktop/src/components/activity/SessionActivityPanel.test.tsx b/desktop/src/components/activity/SessionActivityPanel.test.tsx index ec84458b..9c45fc99 100644 --- a/desktop/src/components/activity/SessionActivityPanel.test.tsx +++ b/desktop/src/components/activity/SessionActivityPanel.test.tsx @@ -137,9 +137,11 @@ describe('SessionActivityPanel', () => { // 30px matches AgentMascot, so SubAgent and background rows share a text column. expect(running?.className).toContain('h-[30px]') - expect(running?.className).toContain('bg-[var(--color-brand-soft)]') + // Running reads as info, never brand: terracotta is not a status colour. + expect(running?.className).toContain('bg-[var(--color-info-container)]') + expect(running?.className).not.toContain('brand') // Paired tokens, never a raw accent on its own container (AGENTS.md 3.2). - expect(running?.className).toContain('text-[var(--color-on-brand-soft)]') + expect(running?.className).toContain('text-[var(--color-on-info-container)]') expect(failed?.className).toContain('bg-[var(--color-error-container)]') expect(failed?.className).toContain('text-[var(--color-on-error-container)]') }) @@ -203,7 +205,7 @@ describe('SessionActivityPanel', () => { expect(screen.getByLabelText('Task completed')).toBeInTheDocument() expect(screen.getByLabelText('Task in progress')).toBeInTheDocument() expect(screen.getByLabelText('Task pending')).toBeInTheDocument() - // The in-progress marker is the design's terracotta ring — a bordered + // The in-progress marker is the design's info-blue ring — a bordered // circle, not an icon — so the animation classes sit on the marker itself. // It still stops rather than slows under reduced motion, which is why this // panel does not use `Spinner`. diff --git a/desktop/src/components/activity/SessionActivityPanel.tsx b/desktop/src/components/activity/SessionActivityPanel.tsx index 8287ea41..1443b405 100644 --- a/desktop/src/components/activity/SessionActivityPanel.tsx +++ b/desktop/src/components/activity/SessionActivityPanel.tsx @@ -165,9 +165,9 @@ function TaskStatusMarker({ status, t }: { status: ActivityRow['status']; t: Tra return ( - ) } @@ -178,7 +178,7 @@ function TaskStatusMarker({ status, t }: { status: ActivityRow['status']; t: Tra aria-label={t('session.activity.status.stopped')} className="inline-flex h-5 w-5 shrink-0 items-center justify-center rounded-full border border-[var(--color-outline)] text-[var(--color-text-tertiary)]" > -