From f9624107a58adfd8c246ea5c66f7d5464f7d2d70 Mon Sep 17 00:00:00 2001 From: truongthinh1412 <9260650+truongthinh1412@users.noreply.github.com> Date: Tue, 27 Oct 2026 22:12:53 +0700 Subject: [PATCH] refactor(ui): migrate app chrome icons to the shared icon module (tranche 1) --- src/App.tsx | 12 +- src/components/NewTaskPlaceholder.tsx | 5 +- src/components/Sidebar.tsx | 58 ++----- src/components/SidebarFooter.tsx | 59 +------ src/components/TaskAITerminal.tsx | 39 ++--- src/components/TaskTitleBar.tsx | 35 ++-- src/components/TilingLayout.tsx | 22 +-- src/components/UpdateButton.tsx | 38 +---- src/components/WindowTitleBar.tsx | 26 +-- src/components/WorkspaceControls.tsx | 17 +- src/components/icons.client.test.tsx | 94 +++++++++++ src/components/icons.tsx | 228 ++++++++++++++++++++++++++ 12 files changed, 381 insertions(+), 252 deletions(-) create mode 100644 src/components/icons.client.test.tsx diff --git a/src/App.tsx b/src/App.tsx index e1afc2e69..fef12a586 100644 --- a/src/App.tsx +++ b/src/App.tsx @@ -163,7 +163,7 @@ import { startAgentHookStatusListener } from './store/agentHookStatus'; import { applyPlanContent } from './store/canvas'; import { startBackgroundChecks } from './store/background-checks'; import type { PlanContentMessage } from './store/canvas'; -import { CircleCheckIcon, SyncIcon } from './components/icons'; +import { CircleCheckIcon, GitHubMarkIcon, SyncIcon } from './components/icons'; const ThinkingSpace = lazy(() => import('./components/thinking/ThinkingSpace').then((m) => ({ default: m.ThinkingSpace })), @@ -193,15 +193,7 @@ function DropOverlay() { > {/* Drag events hide the link until drop, so both kinds are announced. */}
- - - + diff --git a/src/components/NewTaskPlaceholder.tsx b/src/components/NewTaskPlaceholder.tsx index d2288f500..9594f5022 100644 --- a/src/components/NewTaskPlaceholder.tsx +++ b/src/components/NewTaskPlaceholder.tsx @@ -3,6 +3,7 @@ import { toggleNewTaskPanel, createTerminal, unfocusPlaceholder } from '../store import { registerFocusFn, unregisterFocusFn } from '../store/focus'; import { theme } from '../lib/theme'; import { mod } from '../lib/platform'; +import { PlusLargeIcon } from './icons'; import { withViewTransition } from '../lib/viewTransition'; /** Quiet ghost surface: a faint fill instead of a dashed wireframe border, so @@ -63,9 +64,7 @@ export function NewTaskPlaceholder() { style={{ ...ghostStyle, flex: '1' }} title={`New task (${mod}+N)`} > - +
{/* Terminal button — same width, fixed height */} diff --git a/src/components/Sidebar.tsx b/src/components/Sidebar.tsx index 014d8dd78..fbcb1a49d 100644 --- a/src/components/Sidebar.tsx +++ b/src/components/Sidebar.tsx @@ -3,7 +3,7 @@ import { TaskProjectFilter } from './TaskProjectFilter'; import { createProjectFilterTransition } from '../lib/projectFilterTransition'; import { createSignal, createEffect, createMemo, onMount, onCleanup, For, Show } from 'solid-js'; import type { JSX } from 'solid-js'; -import { ClockIcon } from './icons'; +import { BotIcon, ChevronDownAltIcon, ClockIcon, FolderIcon, PlusLargeIcon } from './icons'; import { createTaskContextMenu } from './task-context-menu'; import { errMessage } from '../lib/log'; import { @@ -119,17 +119,7 @@ function createOffscreenAttentionState(taskId: () => string) { /** Small bot/coordinator icon (16x16 SVG). */ function CoordinatorIcon() { - return ( - - - - ); + return ; } function DirectBranchBadge(props: { branchName: string }) { @@ -223,20 +213,14 @@ export function ProjectTaskGroupToggle(props: { gap: '5px', }} > - + /> - + /> - - - } + icon={} onClick={handleAddProject} title="Add project" size="sm" @@ -835,15 +809,7 @@ export function Sidebar() { width: '100%', }} > - + Add project } @@ -867,9 +833,7 @@ export function Sidebar() { width: '100%', }} > - - - + New Task diff --git a/src/components/SidebarFooter.tsx b/src/components/SidebarFooter.tsx index a519051c4..c674b2376 100644 --- a/src/components/SidebarFooter.tsx +++ b/src/components/SidebarFooter.tsx @@ -11,6 +11,7 @@ import { } from '../store/store'; import { theme } from '../lib/theme'; import { alt, mod } from '../lib/platform'; +import { IdeaIcon, KeyboardIcon, PhoneIcon, ScrambleIcon } from './icons'; /** Compact utilities and optional activity totals, shared by every theme. */ export function SidebarFooter(props: { onConnectPhone: () => void }) { @@ -70,20 +71,7 @@ export function SidebarFooter(props: { onConnectPhone: () => void }) { phoneConnected() ? 'Phone connected: manage remote access' : 'Connect phone' } > - + Phone access @@ -647,9 +636,7 @@ function AddAgentMenu(props: { taskId: string }) { padding: '0', }} > - - - +
} > - - - + {props.pushing ? 'Pushing…' : props.pushSuccess ? 'Pushed' : 'Finish'} 0 || generating().length > 0)}> @@ -213,19 +220,7 @@ export function TaskTitleBar(props: TaskTitleBarProps) {
- - - - } + icon={} onClick={() => isTaskCanvasVisible(props.task) ? closeTaskCanvas(props.task.id) @@ -235,11 +230,7 @@ export function TaskTitleBar(props: TaskTitleBarProps) { pressed={isTaskCanvasVisible(props.task)} /> - - - } + icon={} onClick={() => { const taskId = props.task.id; // The button bypasses the title bar's tap-to-activate; activate before the diff --git a/src/components/TilingLayout.tsx b/src/components/TilingLayout.tsx index b18287920..f11f5fcb9 100644 --- a/src/components/TilingLayout.tsx +++ b/src/components/TilingLayout.tsx @@ -46,7 +46,7 @@ import { mod } from '../lib/platform'; import { createCtrlShiftWheelResizeHandler } from '../lib/wheelZoom'; import { prefersMotion } from '../lib/reducedMotion'; import { TASK_TILE_DEFAULT_WIDTH, TASK_TILE_MIN_WIDTH } from '../lib/layout-sizes'; -import { SyncIcon } from './icons'; +import { FolderIcon, SyncIcon } from './icons'; // Lazy: the document workspace (CodeMirror, parse5) loads when its panel opens. const DocumentWorkspacePanel = lazy(() => @@ -664,15 +664,7 @@ export function TilingLayout() { color: theme.fgSubtle, }} > - +
pickAndAddProject()} style={emptyStateButtonStyle()} > - + Add project diff --git a/src/components/UpdateButton.tsx b/src/components/UpdateButton.tsx index 673e07997..1b3f8a850 100644 --- a/src/components/UpdateButton.tsx +++ b/src/components/UpdateButton.tsx @@ -11,41 +11,7 @@ import { Show } from 'solid-js'; import { theme } from '../lib/theme'; import { updateStatus, downloadUpdate, installUpdate } from '../store/store'; - -const DownloadIcon = () => ( - -); - -const RestartIcon = () => ( - -); +import { DownloadAltIcon, RefreshIcon } from './icons'; export function UpdateButton() { const phase = () => updateStatus().phase; @@ -94,7 +60,7 @@ export function UpdateButton() { > : } + fallback={phase() === 'downloaded' ? : } > {updateStatus().downloadPercent}% diff --git a/src/components/WindowTitleBar.tsx b/src/components/WindowTitleBar.tsx index eca16459b..c094ef084 100644 --- a/src/components/WindowTitleBar.tsx +++ b/src/components/WindowTitleBar.tsx @@ -3,7 +3,7 @@ import { WorkspaceControls } from './WorkspaceControls'; import { appWindow } from '../lib/window'; import { FocusModeTaskIndicators } from './FocusModeTaskIndicators'; import { TaskBoardControls } from './TaskBoardControls'; -import { AppLogo } from './icons'; +import { AppLogo, CloseThinIcon, MaximizeIcon, MinimizeIcon, RestoreIcon } from './icons'; /** `bare` keeps only the window's own controls, for the welcome screen. */ export function WindowTitleBar(props: { bare?: boolean }) { @@ -109,9 +109,7 @@ export function WindowTitleBar(props: { bare?: boolean }) { aria-label="Minimize window" title="Minimize" > - +
diff --git a/src/components/WorkspaceControls.tsx b/src/components/WorkspaceControls.tsx index 27bc2eb6a..2ea8231b4 100644 --- a/src/components/WorkspaceControls.tsx +++ b/src/components/WorkspaceControls.tsx @@ -11,6 +11,7 @@ import { } from '../store/store'; import { mod } from '../lib/platform'; import { withViewTransition } from '../lib/viewTransition'; +import { ChevronLeftAltIcon, GearIcon } from './icons'; import { FeatureHint } from './FeatureHint'; import { IconButton } from './IconButton'; import { ToggleButton, ToggleGroup } from './ToggleGroup'; @@ -132,25 +133,15 @@ export function WorkspaceControls() { > - - + /> } onClick={() => toggleSidebar()} title={`${store.sidebarVisible ? 'Collapse' : 'Show'} sidebar (${mod}+B)`} /> - - - } + icon={} onClick={() => toggleSettingsDialog(true)} title={`Settings (${mod}+,)`} /> diff --git a/src/components/icons.client.test.tsx b/src/components/icons.client.test.tsx new file mode 100644 index 000000000..2db3688cc --- /dev/null +++ b/src/components/icons.client.test.tsx @@ -0,0 +1,94 @@ +import type { JSX } from 'solid-js'; +import { render } from 'solid-js/web'; +import { afterEach, describe, expect, it } from 'vitest'; +import * as icons from './icons'; + +type IconComponent = (props: { size?: number; title?: string }) => JSX.Element; + +const disposers: Array<() => void> = []; + +afterEach(() => { + while (disposers.length > 0) disposers.pop()?.(); + document.body.replaceChildren(); +}); + +/** `EvidenceNoteIcon` draws its own 16x18 box at a 14px default and ignores + * `title`, and `AppLogo` is sized by CSS with no size/title props at all, so + * neither fits the standard square-icon assertions below. Both still get a + * render-smoke check at the bottom of this file. */ +const NONSTANDARD_EXPORTS = new Set(['EvidenceNoteIcon', 'AppLogo']); + +/** Every standard exported icon component, so the module stays render-covered. */ +// Match by export key, not `fn.name`: CI's dev transform wraps components and +// renames them to `[solid-refresh]`, which slipped past the exemption. +const ALL_ICONS = Object.entries(icons) + .filter(([name]) => !NONSTANDARD_EXPORTS.has(name)) + .map(([, icon]) => icon); + +describe('shared icon module', () => { + it('renders every icon at its default size of 16 and hides it from the a11y tree', () => { + for (const Icon of ALL_ICONS) { + const container = document.createElement('div'); + document.body.append(container); + disposers.push(render(() => , container)); + + const svg = container.querySelector('svg'); + expect(svg, Icon.name).not.toBeNull(); + expect(svg?.getAttribute('width'), Icon.name).toBe('16'); + expect(svg?.getAttribute('height'), Icon.name).toBe('16'); + expect(svg?.getAttribute('aria-hidden'), Icon.name).toBe('true'); + expect(svg?.getAttribute('role'), Icon.name).toBeNull(); + expect(svg?.querySelector('title'), Icon.name).toBeNull(); + } + }); + + it('honors a custom size (12, the small size from issue #208)', () => { + for (const Icon of ALL_ICONS) { + const container = document.createElement('div'); + document.body.append(container); + disposers.push(render(() => , container)); + + const svg = container.querySelector('svg'); + expect(svg?.getAttribute('width'), Icon.name).toBe('12'); + expect(svg?.getAttribute('height'), Icon.name).toBe('12'); + } + }); + + it('keeps the monochrome currentColor contract', () => { + for (const Icon of ALL_ICONS) { + const container = document.createElement('div'); + document.body.append(container); + disposers.push(render(() => , container)); + + const svg = container.querySelector('svg'); + const fill = svg?.getAttribute('fill'); + expect(['currentColor', 'none'], Icon.name).toContain(fill); + if (fill === 'none') { + expect(svg?.getAttribute('stroke'), Icon.name).toBe('currentColor'); + } + } + }); + + it('renders a title with role="img" when a title is provided', () => { + for (const Icon of ALL_ICONS) { + const container = document.createElement('div'); + document.body.append(container); + disposers.push(render(() => , container)); + + const svg = container.querySelector('svg'); + expect(svg?.getAttribute('aria-hidden'), Icon.name).toBeNull(); + expect(svg?.getAttribute('role'), Icon.name).toBe('img'); + expect(svg?.querySelector('title')?.textContent, Icon.name).toBe('Test label'); + } + }); + + it('still renders the non-standard exports without throwing', () => { + for (const name of NONSTANDARD_EXPORTS) { + const Icon = (icons as unknown as Record)[name]; + const container = document.createElement('div'); + document.body.append(container); + disposers.push(render(() => , container)); + expect(container.querySelector('svg'), name).not.toBeNull(); + } + }); +}); diff --git a/src/components/icons.tsx b/src/components/icons.tsx index 1aa9ec219..1f47c1efb 100644 --- a/src/components/icons.tsx +++ b/src/components/icons.tsx @@ -716,3 +716,231 @@ export function AppLogo(props: { class?: string }): JSX.Element { ); } +interface StrokeSvgIconProps extends IconProps { + children: JSX.Element; + viewBox?: string; + strokeWidth: number | string; + roundCaps?: boolean; + roundJoins?: boolean; +} + +/** Stroke-drawn glyphs (fill="none"): attributes mirror what the call sites pasted. */ +function StrokeSvgIcon(props: StrokeSvgIconProps): JSX.Element { + const size = () => props.size ?? 16; + + return ( + + {props.title ? {props.title} : null} + {props.children} + + ); +} + +/** Thin 10x10 window controls (custom title bar). */ +export function MinimizeIcon(props: IconProps): JSX.Element { + return ( + + + + ); +} + +export function MaximizeIcon(props: IconProps): JSX.Element { + return ( + + + + ); +} + +export function RestoreIcon(props: IconProps): JSX.Element { + return ( + + + + + ); +} + +export function CloseThinIcon(props: IconProps): JSX.Element { + return ( + + + + ); +} + +/** Coordinator/bot marker shown next to agent-driven tasks. */ +export function BotIcon(props: IconProps): JSX.Element { + return ( + + + + ); +} + +/** Filled chevron for collapsible section headers; see also ChevronDownIcon above. */ +export function ChevronDownAltIcon(props: IconProps): JSX.Element { + return ( + + + + ); +} + +/** Chunky plus for "create" affordances; PlusIcon above is the inline-text variant. */ +export function PlusLargeIcon(props: IconProps): JSX.Element { + return ( + + + + ); +} + +/** Compact plus that stays legible at 13px and below. */ +export function PlusSmallIcon(props: IconProps): JSX.Element { + return ( + + + + ); +} + +/** Keyboard: the shortcuts cheat-sheet affordance in the sidebar footer. */ +export function KeyboardIcon(props: IconProps): JSX.Element { + return ( + + + + + ); +} + +/** Side panel with a divider: toggles the task canvas. */ +export function PanelRightIcon(props: IconProps): JSX.Element { + return ( + + + + + ); +} + +/** Corners-in brackets: shown while unfocused as the enter-focus affordance in the document workspace, and always on the task title bar. */ +export function CollapseAltIcon(props: IconProps): JSX.Element { + return ( + + + + ); +} + +/** Check inside a circle: the Finish (merge or push) button's idle glyph. */ +export function CheckCircleIcon(props: IconProps): JSX.Element { + return ( + + + + ); +} + +/** Terminal window chrome (panel-layout toggle); see also the prompt-style TerminalIcon above. */ +export function TerminalAltIcon(props: IconProps): JSX.Element { + return ( + + + + + ); +} + +/** Two side-by-side panels: the split-terminal layout toggle. */ +export function ColumnsIcon(props: IconProps): JSX.Element { + return ( + + + + + ); +} + +/** Phone handset: remote-access affordances. */ +export function PhoneIcon(props: IconProps): JSX.Element { + return ( + + + + + ); +} + +/** Crossing strokes: the Arena (agent-vs-agent) affordance. */ +export function ScrambleIcon(props: IconProps): JSX.Element { + return ( + + + + + ); +} + +/** Stroke-drawn download; see also the filled DownloadIcon above. */ +export function DownloadAltIcon(props: IconProps): JSX.Element { + return ( + + + + + + ); +} + +/** Circular arrow: restart to install an update. */ +export function RefreshIcon(props: IconProps): JSX.Element { + return ( + + + + + ); +} + +/** The GitHub octocat mark; GitHubIcon above is this project's own glyph. */ +export function GitHubMarkIcon(props: IconProps): JSX.Element { + return ( + + + + ); +} + +/** Gear: project settings — name, folder and agents. */ +export function GearIcon(props: IconProps): JSX.Element { + return ( + + + + ); +} + +/** Filled octicon chevron for commit-step navigation; see also the stroke ChevronLeftIcon above. */ +export function ChevronLeftAltIcon(props: IconProps): JSX.Element { + return ( + + + + ); +}