perf(ui): migrate icons to SVG sprite system

Replace @remixicon/react with a shared Icon component that renders
via <use href> references to a single hidden SVG sprite. This reduces
DOM node count by replacing inline SVGs with lightweight references.

- Create Icon component with sprite injection (packages/ui/src/components/icon/)
- Migrate all 164 files from @remixicon/react to Icon component
- Auto-generate sprite data from remixicon bundle (scripts/generate-icon-sprite.mjs)
- Add bun run icons:generate to package.json
- Move @remixicon/react to devDependencies
- Add icon usage instructions to theme-system skill
This commit is contained in:
Bohdan Triapitsyn
2026-05-13 13:26:35 +03:00
parent b4cbd7f0d6
commit 14357257ae
173 changed files with 2342 additions and 2227 deletions
@@ -23,18 +23,6 @@ import {
CommandList,
CommandSeparator,
} from '@/components/ui/command';
import {
RiGitBranchLine,
RiGitRepositoryLine,
RiGithubLine,
RiLoader4Line,
RiRefreshLine,
RiErrorWarningLine,
RiCheckLine,
RiExternalLinkLine,
RiCloseLine,
RiArrowDownSLine,
} from '@remixicon/react';
import { cn } from '@/lib/utils';
import { useProjectsStore } from '@/stores/useProjectsStore';
import { useGitHubAuthStore } from '@/stores/useGitHubAuthStore';
@@ -58,6 +46,7 @@ import { useGitBranches, useGitStore, useGitLoadingBranches } from '@/stores/use
import { GitHubIntegrationDialog } from './GitHubIntegrationDialog';
import { SortableTabsStrip } from '@/components/ui/sortable-tabs-strip';
import { MobileOverlayPanel } from '@/components/ui/MobileOverlayPanel';
import { Icon } from "@/components/icon/Icon";
import type {
GitHubIssue,
GitHubIssueComment,
@@ -1037,7 +1026,7 @@ export function NewWorktreeDialog({
<div className={cn('flex items-center gap-1.5 text-destructive', isMobile ? 'w-full justify-center order-first' : 'mr-auto')}>
{validation.touched && (validation.branchError || validation.worktreeError) && (
<>
<RiErrorWarningLine className="h-3.5 w-3.5" />
<Icon name="error-warning" className="h-3.5 w-3.5" />
<span className="typography-micro">
{validation.branchError || validation.worktreeError}
</span>
@@ -1062,7 +1051,7 @@ export function NewWorktreeDialog({
disabled={!canCreate || isCreating}
className={cn('gap-1.5', isMobile && 'flex-1')}
>
{isCreating && <RiLoader4Line className="h-3.5 w-3.5 animate-spin" />}
{isCreating && <Icon name="loader-4" className="h-3.5 w-3.5 animate-spin" />}
{isCreating ? t('session.newWorktree.actions.creating') : t('session.newWorktree.actions.createWorktree')}
</Button>
</div>
@@ -1082,8 +1071,8 @@ export function NewWorktreeDialog({
<div className="w-full mb-4">
<SortableTabsStrip
items={[
{ id: 'new-branch', label: t('session.newWorktree.mode.newBranch'), icon: <RiGitBranchLine className="h-3.5 w-3.5" /> },
{ id: 'existing-branch', label: t('session.newWorktree.mode.existingBranch'), icon: <RiGitRepositoryLine className="h-3.5 w-3.5" /> },
{ id: 'new-branch', label: t('session.newWorktree.mode.newBranch'), icon: <Icon name="git-branch" className="h-3.5 w-3.5" /> },
{ id: 'existing-branch', label: t('session.newWorktree.mode.existingBranch'), icon: <Icon name="git-repository" className="h-3.5 w-3.5" /> },
]}
activeId={mode}
onSelect={(id) => handleModeChange(id as Mode)}
@@ -1110,7 +1099,7 @@ export function NewWorktreeDialog({
<span className={existingBranchState.selectedBranch ? 'text-foreground' : 'text-muted-foreground'}>
{existingBranchState.selectedBranch || t('session.newWorktree.chooseBranch')}
</span>
<RiGitBranchLine className="h-4 w-4 text-muted-foreground" />
<Icon name="git-branch" className="h-4 w-4 text-muted-foreground" />
</Button>
<Button
variant="ghost"
@@ -1120,7 +1109,7 @@ export function NewWorktreeDialog({
disabled={!canFetchBranches || isLoadingBranches}
title={t('session.newWorktree.fetchBranches')}
>
{isLoadingBranches ? <RiLoader4Line className="size-4 animate-spin" /> : <RiRefreshLine className="size-4" />}
{isLoadingBranches ? <Icon name="loader-4" className="size-4 animate-spin" /> : <Icon name="refresh" className="size-4" />}
</Button>
</div>
@@ -1266,7 +1255,7 @@ export function NewWorktreeDialog({
onClick={() => setGithubDialogOpen(true)}
className="gap-1.5 h-7"
>
<RiGithubLine className="size-4 text-status-success" />
<Icon name="github" className="size-4 text-status-success" />
{newBranchState.linkedIssue || newBranchState.linkedPr ? t('session.newWorktree.actions.change') : t('session.newWorktree.actions.startFromGitHubIssuePr')}
</Button>
)}
@@ -1293,7 +1282,7 @@ export function NewWorktreeDialog({
/>
{newBranchState.linkedPr && (
<div className="flex items-center gap-1.5 text-muted-foreground">
<RiCheckLine className="h-3.5 w-3.5 text-status-success" />
<Icon name="check" className="h-3.5 w-3.5 text-status-success" />
<span className="typography-micro">
{t('session.newWorktree.usingPrBranch', { branch: newBranchState.linkedPr.head })}
</span>
@@ -1301,7 +1290,7 @@ export function NewWorktreeDialog({
)}
{newBranchState.linkedIssue && !newBranchState.linkedPr && (
<div className="flex items-center gap-1.5 text-muted-foreground">
<RiCheckLine className="h-3.5 w-3.5 text-status-success" />
<Icon name="check" className="h-3.5 w-3.5 text-status-success" />
<span className="typography-micro">
{t('session.newWorktree.fromIssue', { number: newBranchState.linkedIssue.number, title: newBranchState.linkedIssue.title })}
</span>
@@ -1335,7 +1324,7 @@ export function NewWorktreeDialog({
)}
title={t('session.newWorktree.resetToMatchBranchName')}
>
<RiRefreshLine className="h-3 w-3" />
<Icon name="refresh" className="h-3 w-3" />
<span>{t('session.newWorktree.actions.reset')}</span>
</button>
)}
@@ -1380,7 +1369,7 @@ export function NewWorktreeDialog({
<span className={newBranchState.sourceBranch ? 'text-foreground' : 'text-muted-foreground'}>
{newBranchState.sourceBranch || t('session.newWorktree.selectSourceBranchPlaceholder')}
</span>
<RiGitBranchLine className="h-4 w-4 text-muted-foreground" />
<Icon name="git-branch" className="h-4 w-4 text-muted-foreground" />
</Button>
{newBranchState.sourceBranch && (
<div className="typography-micro text-muted-foreground">
@@ -1509,7 +1498,7 @@ export function NewWorktreeDialog({
<div className="mt-2 px-2 py-1.5 rounded bg-muted/30">
{/* Row 1: Type, number, title, actions */}
<div className="flex items-center gap-2">
<RiGithubLine className="h-3.5 w-3.5 text-status-success shrink-0" />
<Icon name="github" className="h-3.5 w-3.5 text-status-success shrink-0" />
{newBranchState.linkedIssue && (
<span className="typography-micro text-muted-foreground shrink-0">
@@ -1533,14 +1522,14 @@ export function NewWorktreeDialog({
className="text-muted-foreground hover:text-foreground shrink-0"
onClick={(e) => e.stopPropagation()}
>
<RiExternalLinkLine className="h-3 w-3" />
<Icon name="external-link" className="h-3 w-3" />
</a>
<button
onClick={handleClearLinkedItem}
className="text-muted-foreground hover:text-foreground shrink-0 p-0.5 rounded hover:bg-muted transition-colors"
>
<RiCloseLine className="h-3.5 w-3.5" />
<Icon name="close" className="h-3.5 w-3.5" />
</button>
</div>
@@ -1567,7 +1556,7 @@ export function NewWorktreeDialog({
<DialogHeader className="flex flex-row items-center justify-between">
<div className="flex items-center gap-3">
<DialogTitle className="flex items-center gap-2 shrink-0">
<RiGitBranchLine className="h-5 w-5" />
<Icon name="git-branch" className="h-5 w-5" />
{t('session.newWorktree.title')}
</DialogTitle>
@@ -1575,8 +1564,8 @@ export function NewWorktreeDialog({
<div className="w-[280px] shrink-0">
<SortableTabsStrip
items={[
{ id: 'new-branch', label: t('session.newWorktree.mode.newBranch'), icon: <RiGitBranchLine className="h-3.5 w-3.5" /> },
{ id: 'existing-branch', label: t('session.newWorktree.mode.existingBranch'), icon: <RiGitRepositoryLine className="h-3.5 w-3.5" /> },
{ id: 'new-branch', label: t('session.newWorktree.mode.newBranch'), icon: <Icon name="git-branch" className="h-3.5 w-3.5" /> },
{ id: 'existing-branch', label: t('session.newWorktree.mode.existingBranch'), icon: <Icon name="git-repository" className="h-3.5 w-3.5" /> },
]}
activeId={mode}
onSelect={(id) => handleModeChange(id as Mode)}
@@ -1602,7 +1591,7 @@ export function NewWorktreeDialog({
<span className={cn('truncate', existingBranchState.selectedBranch ? 'text-foreground' : 'text-muted-foreground')}>
{existingBranchState.selectedBranch || t('session.newWorktree.chooseBranch')}
</span>
<RiArrowDownSLine className="h-4 w-4 shrink-0 text-muted-foreground" />
<Icon name="arrow-down-s" className="h-4 w-4 shrink-0 text-muted-foreground" />
</Button>
</DropdownMenuTrigger>
<DropdownMenuContent align="start" sideOffset={6} portalToBody className="w-[min(42rem,calc(100vw-2rem))] p-0 max-h-[min(var(--available-height),24rem)] flex flex-col overflow-hidden" ref={existingBranchDropdownContentRef}>
@@ -1714,7 +1703,7 @@ export function NewWorktreeDialog({
disabled={!canFetchBranches || isLoadingBranches}
title={t('session.newWorktree.fetchBranches')}
>
{isLoadingBranches ? <RiLoader4Line className="size-4 animate-spin" /> : <RiRefreshLine className="size-4" />}
{isLoadingBranches ? <Icon name="loader-4" className="size-4 animate-spin" /> : <Icon name="refresh" className="size-4" />}
</Button>
</div>
</div>
@@ -1731,7 +1720,7 @@ export function NewWorktreeDialog({
onClick={() => setGithubDialogOpen(true)}
className="gap-1.5 h-7"
>
<RiGithubLine className="size-4 text-status-success" />
<Icon name="github" className="size-4 text-status-success" />
{newBranchState.linkedIssue || newBranchState.linkedPr ? t('session.newWorktree.actions.change') : t('session.newWorktree.actions.startFromGitHubIssuePr')}
</Button>
)}
@@ -1758,7 +1747,7 @@ export function NewWorktreeDialog({
/>
{newBranchState.linkedPr && (
<div className="flex items-center gap-1.5 text-muted-foreground">
<RiCheckLine className="h-3.5 w-3.5 text-status-success" />
<Icon name="check" className="h-3.5 w-3.5 text-status-success" />
<span className="typography-micro">
{t('session.newWorktree.usingPrBranch', { branch: newBranchState.linkedPr.head })}
</span>
@@ -1766,7 +1755,7 @@ export function NewWorktreeDialog({
)}
{newBranchState.linkedIssue && !newBranchState.linkedPr && (
<div className="flex items-center gap-1.5 text-muted-foreground">
<RiCheckLine className="h-3.5 w-3.5 text-status-success" />
<Icon name="check" className="h-3.5 w-3.5 text-status-success" />
<span className="typography-micro">
{t('session.newWorktree.fromIssue', { number: newBranchState.linkedIssue.number, title: newBranchState.linkedIssue.title })}
</span>
@@ -1800,7 +1789,7 @@ export function NewWorktreeDialog({
)}
title={t('session.newWorktree.resetToMatchBranchName')}
>
<RiRefreshLine className="h-3 w-3" />
<Icon name="refresh" className="h-3 w-3" />
<span>{t('session.newWorktree.actions.reset')}</span>
</button>
)}
@@ -1842,7 +1831,7 @@ export function NewWorktreeDialog({
<span className={cn('truncate', newBranchState.sourceBranch ? 'text-foreground' : 'text-muted-foreground')}>
{newBranchState.sourceBranch || t('session.newWorktree.selectSourceBranchPlaceholder')}
</span>
<RiArrowDownSLine className="h-4 w-4 shrink-0 text-muted-foreground" />
<Icon name="arrow-down-s" className="h-4 w-4 shrink-0 text-muted-foreground" />
</Button>
</DropdownMenuTrigger>
<DropdownMenuContent align="start" portalToBody className="w-[min(42rem,calc(100vw-2rem))] p-0 max-h-[min(var(--available-height),24rem)] flex flex-col overflow-hidden" ref={sourceBranchDropdownContentRef}>
@@ -1944,7 +1933,7 @@ export function NewWorktreeDialog({
<div className="mt-2 px-2 py-1.5 rounded bg-muted/30">
{/* Row 1: Type, number, title, actions */}
<div className="flex items-center gap-2">
<RiGithubLine className="h-3.5 w-3.5 text-status-success shrink-0" />
<Icon name="github" className="h-3.5 w-3.5 text-status-success shrink-0" />
{newBranchState.linkedIssue && (
<span className="typography-micro text-muted-foreground shrink-0">
@@ -1968,14 +1957,14 @@ export function NewWorktreeDialog({
className="text-muted-foreground hover:text-foreground shrink-0"
onClick={(e) => e.stopPropagation()}
>
<RiExternalLinkLine className="h-3 w-3" />
<Icon name="external-link" className="h-3 w-3" />
</a>
<button
onClick={handleClearLinkedItem}
className="text-muted-foreground hover:text-foreground shrink-0 p-0.5 rounded hover:bg-muted transition-colors"
>
<RiCloseLine className="h-3.5 w-3.5" />
<Icon name="close" className="h-3.5 w-3.5" />
</button>
</div>
@@ -2002,7 +1991,7 @@ export function NewWorktreeDialog({
<div className="flex items-center gap-1.5 text-destructive">
{validation.touched && (validation.branchError || validation.worktreeError) && (
<>
<RiErrorWarningLine className="h-3.5 w-3.5" />
<Icon name="error-warning" className="h-3.5 w-3.5" />
<span className="typography-micro">
{validation.branchError || validation.worktreeError}
</span>
@@ -2025,7 +2014,7 @@ export function NewWorktreeDialog({
disabled={!canCreate || isCreating}
className="gap-1.5"
>
{isCreating && <RiLoader4Line className="h-3.5 w-3.5 animate-spin" />}
{isCreating && <Icon name="loader-4" className="h-3.5 w-3.5 animate-spin" />}
{isCreating ? t('session.newWorktree.actions.creating') : t('session.newWorktree.actions.createWorktree')}
</Button>
</div>