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
@@ -1,12 +1,4 @@
import React from 'react';
import {
RiAddLine,
RiArrowDownSLine,
RiArrowRightSLine,
RiDeleteBinLine,
RiInformationLine,
RiPlayLine,
} from '@remixicon/react';
import { Button } from '@/components/ui/button';
import { Checkbox } from '@/components/ui/checkbox';
import {
@@ -30,6 +22,7 @@ import { Input } from '@/components/ui/input';
import { Textarea } from '@/components/ui/textarea';
import { Tooltip, TooltipContent, TooltipTrigger } from '@/components/ui/tooltip';
import { toast } from '@/components/ui';
import { Icon } from "@/components/icon/Icon";
import { useDesktopSshStore } from '@/stores/useDesktopSshStore';
import { isDesktopShell } from '@/lib/desktop';
import {
@@ -201,7 +194,7 @@ export const ProjectActionsSection: React.FC<ProjectActionsSectionProps> = ({ pr
<p className="typography-meta text-muted-foreground">{t('settings.projects.actions.description')}</p>
</div>
<Button type="button" variant="outline" size="xs" className="!font-normal" onClick={handleAddAction}>
<RiAddLine className="h-3.5 w-3.5" />
<Icon name="add" className="h-3.5 w-3.5" />
{t('settings.projects.actions.actions.add')}
</Button>
</div>
@@ -217,7 +210,7 @@ export const ProjectActionsSection: React.FC<ProjectActionsSectionProps> = ({ pr
<div className="space-y-0 max-w-[30rem]">
{actions.map((action) => {
const selectedIconKey = (action.icon as keyof typeof PROJECT_ACTION_ICON_MAP) || 'play';
const SelectedIcon = PROJECT_ACTION_ICON_MAP[selectedIconKey] || RiPlayLine;
const selectedIconName = PROJECT_ACTION_ICON_MAP[selectedIconKey] || 'play';
const isOpen = expandedActions[action.id] ?? false;
const title = action.name.trim() || t('settings.projects.actions.state.untitled');
@@ -238,11 +231,11 @@ export const ProjectActionsSection: React.FC<ProjectActionsSectionProps> = ({ pr
<div className="flex items-start gap-2">
<CollapsibleTrigger className="group flex-1 justify-start gap-2 rounded-md px-0 pr-1 py-1 hover:bg-[var(--interactive-hover)] focus-visible:ring-2 focus-visible:ring-[var(--interactive-focus-ring)]">
{isOpen ? (
<RiArrowDownSLine className="h-4 w-4 text-muted-foreground" />
<Icon name="arrow-down-s" className="h-4 w-4 text-muted-foreground" />
) : (
<RiArrowRightSLine className="h-4 w-4 text-muted-foreground" />
<Icon name="arrow-right-s" className="h-4 w-4 text-muted-foreground" />
)}
<SelectedIcon className="mt-0.5 h-4 w-4 shrink-0 text-muted-foreground" />
<Icon name={selectedIconName} className="mt-0.5 h-4 w-4 shrink-0 text-muted-foreground" />
<div className="min-w-0 flex-1">
<div className="flex items-center gap-2">
<span className="typography-ui-label text-foreground truncate">{title}</span>
@@ -257,7 +250,7 @@ export const ProjectActionsSection: React.FC<ProjectActionsSectionProps> = ({ pr
className="!font-normal h-7 w-7 px-0 text-muted-foreground hover:text-[var(--status-error)]"
onClick={() => handleRemoveAction(action.id)}
>
<RiDeleteBinLine className="h-3.5 w-3.5" />
<Icon name="delete-bin" className="h-3.5 w-3.5" />
</Button>
</div>
@@ -271,13 +264,13 @@ export const ProjectActionsSection: React.FC<ProjectActionsSectionProps> = ({ pr
className="inline-flex h-7 w-7 shrink-0 items-center justify-center rounded-md border border-[var(--interactive-border)] text-foreground hover:bg-[var(--interactive-hover)]"
aria-label={t('settings.projects.actions.field.selectIconAria')}
>
<SelectedIcon className="h-4 w-4" />
<Icon name={selectedIconName} className="h-4 w-4" />
</button>
</DropdownMenuTrigger>
<DropdownMenuContent align="start" className="w-56 p-2">
<div className="grid grid-cols-6 gap-1">
{PROJECT_ACTION_ICONS.map((entry) => {
const Icon = entry.Icon;
const iconName = entry.Icon;
const selected = (action.icon || 'play') === entry.key;
return (
<button
@@ -290,7 +283,7 @@ export const ProjectActionsSection: React.FC<ProjectActionsSectionProps> = ({ pr
)}
aria-label={t('settings.projects.actions.field.iconAria', { icon: entry.label })}
>
<Icon className="h-4 w-4" />
<Icon name={iconName} className="h-4 w-4" />
</button>
);
})}
@@ -364,7 +357,7 @@ export const ProjectActionsSection: React.FC<ProjectActionsSectionProps> = ({ pr
/>
<Tooltip>
<TooltipTrigger asChild>
<RiInformationLine className="h-3.5 w-3.5 shrink-0 text-muted-foreground/60 cursor-help" />
<Icon name="information" className="h-3.5 w-3.5 shrink-0 text-muted-foreground/60 cursor-help" />
</TooltipTrigger>
<TooltipContent sideOffset={8} className="max-w-xs">
{t('settings.projects.actions.field.overrideUrlTooltip')}
@@ -7,9 +7,9 @@ import { cn } from '@/lib/utils';
import { useProjectsStore } from '@/stores/useProjectsStore';
import { useUIStore } from '@/stores/useUIStore';
import { PROJECT_COLORS, PROJECT_ICONS, PROJECT_COLOR_MAP as COLOR_MAP, getProjectIconImageUrl } from '@/lib/projectMeta';
import { RiCloseLine } from '@remixicon/react';
import { WorktreeSectionContent } from '@/components/sections/openchamber/WorktreeSectionContent';
import { ProjectActionsSection } from '@/components/sections/projects/ProjectActionsSection';
import { Icon } from "@/components/icon/Icon";
import { useThemeSystem } from '@/contexts/useThemeSystem';
import { useI18n } from '@/lib/i18n';
@@ -298,7 +298,7 @@ export const ProjectsPage: React.FC = () => {
)}
title={t('settings.projects.page.field.none')}
>
<RiCloseLine className="h-4 w-4 text-muted-foreground" />
<Icon name="close" className="h-4 w-4 text-muted-foreground" />
</button>
{PROJECT_COLORS.map((c) => (
<button
@@ -346,10 +346,10 @@ export const ProjectsPage: React.FC = () => {
)}
title={t('settings.projects.page.field.none')}
>
<RiCloseLine className="h-4 w-4 text-muted-foreground" />
<Icon name="close" className="h-4 w-4 text-muted-foreground" />
</button>
{PROJECT_ICONS.map((i) => {
const IconComponent = i.Icon;
const iconName = i.Icon;
return (
<button
key={i.key}
@@ -363,7 +363,7 @@ export const ProjectsPage: React.FC = () => {
)}
title={i.label}
>
<IconComponent className="w-4 h-4" style={currentColorVar && icon === i.key ? { color: currentColorVar } : undefined} />
<Icon name={iconName} className="w-4 h-4" style={currentColorVar && icon === i.key ? { color: currentColorVar } : undefined} />
</button>
);
})}
@@ -412,7 +412,7 @@ export const ProjectsPage: React.FC = () => {
title={t('settings.projects.page.field.clearBackground')}
disabled={!iconBackground}
>
<RiCloseLine className="h-3.5 w-3.5" />
<Icon name="close" className="h-3.5 w-3.5" />
</Button>
</div>
)}
@@ -4,9 +4,9 @@ import { useUIStore } from '@/stores/useUIStore';
import { Button } from '@/components/ui/button';
import { SettingsSidebarLayout } from '@/components/sections/shared/SettingsSidebarLayout';
import { SettingsSidebarItem } from '@/components/sections/shared/SettingsSidebarItem';
import { Icon } from "@/components/icon/Icon";
import { PROJECT_COLOR_MAP, PROJECT_ICON_MAP, getProjectIconImageUrl } from '@/lib/projectMeta';
import { cn } from '@/lib/utils';
import { RiAddLine, RiFolderLine } from '@remixicon/react';
import { isVSCodeRuntime } from '@/lib/desktop';
import { sessionEvents } from '@/lib/sessionEvents';
import { useThemeSystem } from '@/contexts/useThemeSystem';
@@ -56,7 +56,7 @@ export const ProjectsSidebar: React.FC<{ onItemSelect?: () => void }> = ({ onIte
onClick={handleAddProject}
aria-label={t('settings.projects.sidebar.actions.addProject')}
>
<RiAddLine className="size-4" />
<Icon name="add" className="size-4" />
</Button>
)}
</div>
@@ -65,7 +65,7 @@ export const ProjectsSidebar: React.FC<{ onItemSelect?: () => void }> = ({ onIte
>
{projects.map((project) => {
const selected = project.id === selectedId;
const Icon = project.icon ? PROJECT_ICON_MAP[project.icon] : null;
const iconName = project.icon ? PROJECT_ICON_MAP[project.icon] : null;
const imageFailureKey = `${project.id}:${project.iconImage?.updatedAt ?? 0}`;
const imageUrl = brokenIconIds.has(imageFailureKey)
? null
@@ -98,12 +98,12 @@ export const ProjectsSidebar: React.FC<{ onItemSelect?: () => void }> = ({ onIte
/>
</span>
)
: Icon
: iconName
? (
<Icon className={cn('h-4 w-4', selected ? 'text-foreground' : 'text-muted-foreground/70')} style={color ? { color } : undefined} />
<Icon name={iconName} className={cn('h-4 w-4', selected ? 'text-foreground' : 'text-muted-foreground/70')} style={color ? { color } : undefined} />
)
: (
<RiFolderLine className={cn('h-4 w-4', selected ? 'text-foreground' : 'text-muted-foreground/70')} style={color ? { color } : undefined} />
<Icon name="folder" className={cn('h-4 w-4', selected ? 'text-foreground' : 'text-muted-foreground/70')} style={color ? { color } : undefined} />
);
return (