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
@@ -12,8 +12,8 @@ import {
DropdownMenuTrigger,
} from '@/components/ui/dropdown-menu';
import { toast } from '@/components/ui';
import { RiStackLine, RiToolsLine, RiBrainAi3Line, RiFileImageLine, RiArrowDownSLine, RiCheckLine, RiSearchLine, RiInformationLine, RiEyeLine, RiEyeOffLine } from '@remixicon/react';
import { Tooltip, TooltipContent, TooltipTrigger } from '@/components/ui/tooltip';
import { Icon } from "@/components/icon/Icon";
import { reloadOpenCodeConfiguration } from '@/stores/useAgentsStore';
import { cn } from '@/lib/utils';
import { copyTextToClipboard } from '@/lib/clipboard';
@@ -511,7 +511,7 @@ export const ProvidersPage: React.FC = () => {
return (
<div className="flex h-full items-center justify-center">
<div className="text-center text-muted-foreground">
<RiStackLine className="mx-auto mb-3 h-12 w-12 opacity-50" />
<Icon name="stack" className="mx-auto mb-3 h-12 w-12 opacity-50" />
<p className="typography-body">{t('settings.providers.page.empty.noProvidersDetected')}</p>
<p className="typography-meta mt-1 opacity-75">{t('settings.providers.page.empty.checkOpenCodeConfiguration')}</p>
</div>
@@ -561,7 +561,7 @@ export const ProvidersPage: React.FC = () => {
: t('settings.providers.page.connect.selectProviderPlaceholder')}
</span>
</span>
<RiArrowDownSLine className="h-4 w-4 flex-shrink-0 text-muted-foreground/50" />
<Icon name="arrow-down-s" className="h-4 w-4 flex-shrink-0 text-muted-foreground/50" />
</button>
</DropdownMenuTrigger>
<DropdownMenuContent
@@ -573,7 +573,7 @@ export const ProvidersPage: React.FC = () => {
className="flex items-center gap-2 border-b border-[var(--surface-subtle)] px-3 py-2"
onKeyDown={(e) => e.stopPropagation()}
>
<RiSearchLine className="h-4 w-4 text-muted-foreground" />
<Icon name="search" className="h-4 w-4 text-muted-foreground" />
<input
type="text"
value={providerSearchQuery}
@@ -608,7 +608,7 @@ export const ProvidersPage: React.FC = () => {
<span className="truncate">{provider.name || provider.id}</span>
</span>
{candidateProviderId === provider.id && (
<RiCheckLine className="h-4 w-4 text-[var(--primary-base)]" />
<Icon name="check" className="h-4 w-4 text-[var(--primary-base)]" />
)}
</DropdownMenuItem>
));
@@ -636,7 +636,7 @@ export const ProvidersPage: React.FC = () => {
{t('settings.providers.page.auth.apiKeyLabel')}
<Tooltip>
<TooltipTrigger asChild>
<RiInformationLine className="h-3.5 w-3.5 text-muted-foreground/60 cursor-help" />
<Icon name="information" className="h-3.5 w-3.5 text-muted-foreground/60 cursor-help" />
</TooltipTrigger>
<TooltipContent sideOffset={8} className="max-w-xs">
{t('settings.providers.page.auth.apiKeyTooltip')}
@@ -772,7 +772,7 @@ export const ProvidersPage: React.FC = () => {
return (
<div className="flex h-full items-center justify-center">
<div className="text-center text-muted-foreground">
<RiStackLine className="mx-auto mb-3 h-12 w-12 opacity-50" />
<Icon name="stack" className="mx-auto mb-3 h-12 w-12 opacity-50" />
<p className="typography-body">{t('settings.providers.page.empty.selectProviderFromSidebar')}</p>
<p className="typography-meta mt-1 opacity-75">{t('settings.providers.page.empty.reviewDetailsAndConfigureAuth')}</p>
</div>
@@ -826,7 +826,7 @@ export const ProvidersPage: React.FC = () => {
<section className="px-2 pb-2 pt-0">
{!showAuthPanel ? (
<div className="flex items-center gap-1.5 py-1.5">
<RiCheckLine className="w-4 h-4 text-[var(--status-success)] shrink-0" />
<Icon name="check" className="w-4 h-4 text-[var(--status-success)] shrink-0" />
<span className="typography-ui-label text-foreground">{t('settings.providers.page.auth.connected')}</span>
<span className="typography-meta text-muted-foreground ml-1">{t('settings.providers.page.auth.useReconnectHint')}</span>
</div>
@@ -839,7 +839,7 @@ export const ProvidersPage: React.FC = () => {
{t('settings.providers.page.auth.apiKeyLabel')}
<Tooltip>
<TooltipTrigger asChild>
<RiInformationLine className="h-3.5 w-3.5 text-muted-foreground/60 cursor-help" />
<Icon name="information" className="h-3.5 w-3.5 text-muted-foreground/60 cursor-help" />
</TooltipTrigger>
<TooltipContent sideOffset={8} className="max-w-xs">
{t('settings.providers.page.auth.apiKeyTooltip')}
@@ -1031,7 +1031,7 @@ export const ProvidersPage: React.FC = () => {
<section className="px-2 pb-2 pt-0">
<div className="relative mb-2">
<RiSearchLine className="absolute left-2 top-1/2 -translate-y-1/2 h-3.5 w-3.5 text-muted-foreground" />
<Icon name="search" className="absolute left-2 top-1/2 -translate-y-1/2 h-3.5 w-3.5 text-muted-foreground" />
<Input
value={modelQuery}
onChange={(event) => setModelQuery(event.target.value)}
@@ -1055,10 +1055,10 @@ export const ProvidersPage: React.FC = () => {
const contextTokens = formatTokens(metadata?.limit?.context);
const outputTokens = formatTokens(metadata?.limit?.output);
const capabilityIcons: Array<{ key: string; icon: typeof RiToolsLine; label: string }> = [];
if (metadata?.tool_call) capabilityIcons.push({ key: 'tools', icon: RiToolsLine, label: t('settings.providers.page.models.capability.toolCalling') });
if (metadata?.reasoning) capabilityIcons.push({ key: 'reasoning', icon: RiBrainAi3Line, label: t('settings.providers.page.models.capability.reasoning') });
if (metadata?.attachment) capabilityIcons.push({ key: 'image', icon: RiFileImageLine, label: t('settings.providers.page.models.capability.imageInput') });
const capabilityIcons: Array<{ key: string; icon: string; label: string }> = [];
if (metadata?.tool_call) capabilityIcons.push({ key: 'tools', icon: "tools", label: t('settings.providers.page.models.capability.toolCalling') });
if (metadata?.reasoning) capabilityIcons.push({ key: 'reasoning', icon: "brain-ai-3", label: t('settings.providers.page.models.capability.reasoning') });
if (metadata?.attachment) capabilityIcons.push({ key: 'image', icon: "file-image", label: t('settings.providers.page.models.capability.imageInput') });
return (
<div key={modelId} className="py-1.5">
@@ -1081,14 +1081,14 @@ export const ProvidersPage: React.FC = () => {
)}
{capabilityIcons.length > 0 && (
<div className="flex items-center gap-1 flex-shrink-0">
{capabilityIcons.map(({ key, icon: Icon, label }) => (
{capabilityIcons.map(({ key, icon: iconName, label }) => (
<span
key={key}
className="flex h-5 w-5 rounded items-center justify-center text-muted-foreground bg-[var(--surface-muted)]"
title={label}
aria-label={label}
>
<Icon className="h-3 w-3" />
<Icon name={iconName} className="h-3 w-3" />
</span>
))}
</div>
@@ -1100,7 +1100,7 @@ export const ProvidersPage: React.FC = () => {
title={isHidden ? t('settings.providers.page.models.actions.showModelInSelectors') : t('settings.providers.page.models.actions.hideModelFromSelectors')}
aria-label={isHidden ? t('settings.providers.page.models.actions.showModel') : t('settings.providers.page.models.actions.hideModel')}
>
{isHidden ? <RiEyeOffLine className="h-3.5 w-3.5" /> : <RiEyeLine className="h-3.5 w-3.5" />}
{isHidden ? <Icon name="eye-off" className="h-3.5 w-3.5" /> : <Icon name="eye" className="h-3.5 w-3.5" />}
</button>
</div>
</div>
@@ -4,9 +4,9 @@ import { ProviderLogo } from '@/components/ui/ProviderLogo';
import { Button } from '@/components/ui/button';
import { useConfigStore } from '@/stores/useConfigStore';
import { useProjectsStore } from '@/stores/useProjectsStore';
import { RiAddLine, RiStackLine } from '@remixicon/react';
import { cn } from '@/lib/utils';
import { SettingsProjectSelector } from '@/components/sections/shared/SettingsProjectSelector';
import { Icon } from "@/components/icon/Icon";
import { opencodeClient } from '@/lib/opencode/client';
import { useI18n } from '@/lib/i18n';
@@ -122,7 +122,7 @@ export const ProvidersSidebar: React.FC<ProvidersSidebarProps> = ({ onItemSelect
aria-label={t('settings.providers.sidebar.actions.connectProviderAria')}
title={t('settings.providers.sidebar.actions.connectProviderTitle')}
>
<RiAddLine className="h-3.5 w-3.5" />
<Icon name="add" className="h-3.5 w-3.5" />
</Button>
</div>
</div>
@@ -130,7 +130,7 @@ export const ProvidersSidebar: React.FC<ProvidersSidebarProps> = ({ onItemSelect
<ScrollableOverlay outerClassName="flex-1 min-h-0" className="space-y-1 px-3 py-2 overflow-x-hidden">
{providers.length === 0 ? (
<div className="py-12 px-4 text-center text-muted-foreground">
<RiStackLine className="mx-auto mb-3 h-10 w-10 opacity-50" />
<Icon name="stack" className="mx-auto mb-3 h-10 w-10 opacity-50" />
<p className="typography-ui-label font-medium">{t('settings.providers.sidebar.empty.title')}</p>
<p className="typography-meta mt-1 opacity-75">{t('settings.providers.sidebar.empty.description')}</p>
</div>