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
@@ -9,15 +9,6 @@ import type { Session } from '@opencode-ai/sdk/v2';
import type { ProjectEntry } from '@/lib/api/types';
import { cn, formatDirectoryName } from '@/lib/utils';
import { getAgentColor } from '@/lib/agentColors';
import {
RiLoader4Line,
RiAddLine,
RiDragMove2Line,
RiDeleteBinLine,
RiEditLine,
RiArrowUpLine,
RiArrowDownLine,
} from '@remixicon/react';
import {
DndContext,
closestCenter,
@@ -51,6 +42,7 @@ import { Button } from '@/components/ui/button';
import { ProjectEditDialog } from '@/components/layout/ProjectEditDialog';
import { useDrawerSwipe } from '@/hooks/useDrawerSwipe';
import { MobileOverlayPanel } from '@/components/ui/MobileOverlayPanel';
import { Icon } from "@/components/icon/Icon";
import { useThemeSystem } from '@/contexts/useThemeSystem';
import { useNotificationStore } from '@/sync/notification-store';
import { useI18n } from '@/lib/i18n';
@@ -275,7 +267,7 @@ function useProjectStatus(
function StatusIndicator({ isRunning, needsAttention }: { isRunning: boolean; needsAttention: boolean }) {
if (isRunning) {
return <RiLoader4Line className="h-2.5 w-2.5 animate-spin text-[var(--status-info)]" />;
return <Icon name="loader-4" className="h-2.5 w-2.5 animate-spin text-[var(--status-info)]" />;
}
if (needsAttention) {
return <div className="h-1.5 w-1.5 rounded-full bg-[var(--status-error)]" />;
@@ -287,7 +279,7 @@ function RunningIndicator({ count }: { count: number }) {
if (count === 0) return null;
return (
<span className="flex items-center gap-1 text-[13px] text-[var(--status-info)]">
<RiLoader4Line className="h-3.5 w-3.5 animate-spin" />
<Icon name="loader-4" className="h-3.5 w-3.5 animate-spin" />
{count}
</span>
);
@@ -369,10 +361,9 @@ function SessionItem({
className="flex-shrink-0"
title={`Sub-session: ${getSessionTitle(child)}`}
>
<RiLoader4Line
className="h-2.5 w-2.5 animate-spin"
style={{ color: `var(${childColor.var})` }}
/>
<Icon name="loader-4" className="h-2.5 w-2.5 animate-spin"
style={{ color: `var(${childColor.var})` }}/>
</div>
);
})}
@@ -428,7 +419,7 @@ function SessionStatusHeader({
childIndicators = []
}: SessionStatusHeaderProps) {
const [imageFailed, setImageFailed] = React.useState(false);
const ProjectIcon = currentProjectIcon ? PROJECT_ICON_MAP[currentProjectIcon] : null;
const projectIconName = currentProjectIcon ? PROJECT_ICON_MAP[currentProjectIcon] : null;
const imageUrl = !imageFailed ? currentProjectIconImageUrl : null;
const projectColorVar = currentProjectColor ? (PROJECT_COLOR_MAP[currentProjectColor] ?? null) : null;
const extraCount = childIndicators.length > 3 ? childIndicators.length - 3 : 0;
@@ -459,8 +450,8 @@ function SessionStatusHeader({
onError={() => setImageFailed(true)}
/>
</span>
) : ProjectIcon && (
<ProjectIcon
) : projectIconName && (
<Icon name={projectIconName}
className="h-2.5 w-2.5"
style={projectColorVar ? { color: projectColorVar } : undefined}
/>
@@ -492,10 +483,9 @@ function SessionStatusHeader({
className="flex-shrink-0"
title={`Sub-session: ${child.session.title || 'Untitled'}`}
>
<RiLoader4Line
className="h-2.5 w-2.5 animate-spin"
style={{ color: `var(${childColor.var})` }}
/>
<Icon name="loader-4" className="h-2.5 w-2.5 animate-spin"
style={{ color: `var(${childColor.var})` }}/>
</div>
);
})}
@@ -513,8 +503,6 @@ function SessionStatusHeader({
);
}
// Hook for long press with movement detection
function useLongPress(
onLongPress: () => void,
@@ -625,7 +613,7 @@ function SortableProjectItem({
};
const [imageFailed, setImageFailed] = React.useState(false);
const ProjectIcon = project.icon ? PROJECT_ICON_MAP[project.icon] : null;
const projectIconName = project.icon ? PROJECT_ICON_MAP[project.icon] : null;
const projectIconImageUrl = !imageFailed
? getProjectIconImageUrl(project, {
themeVariant: currentTheme.metadata.variant,
@@ -650,7 +638,7 @@ function SortableProjectItem({
{...attributes}
{...listeners}
>
<RiDragMove2Line className="h-4 w-4" />
<Icon name="drag-move-2" className="h-4 w-4" />
</button>
{/* Project info */}
@@ -668,8 +656,8 @@ function SortableProjectItem({
onError={() => setImageFailed(true)}
/>
</span>
) : ProjectIcon ? (
<ProjectIcon
) : projectIconName ? (
<Icon name={projectIconName}
className="h-5 w-5 flex-shrink-0"
style={projectColorVar ? { color: projectColorVar } : undefined}
/>
@@ -690,7 +678,7 @@ function SortableProjectItem({
disabled={isFirst}
className="p-1.5 rounded text-[var(--surface-mutedForeground)] hover:text-[var(--surface-foreground)] hover:bg-[var(--interactive-hover)] disabled:opacity-30 disabled:cursor-not-allowed"
>
<RiArrowUpLine className="h-4 w-4" />
<Icon name="arrow-up" className="h-4 w-4" />
</button>
<button
type="button"
@@ -698,7 +686,7 @@ function SortableProjectItem({
disabled={isLast}
className="p-1.5 rounded text-[var(--surface-mutedForeground)] hover:text-[var(--surface-foreground)] hover:bg-[var(--interactive-hover)] disabled:opacity-30 disabled:cursor-not-allowed"
>
<RiArrowDownLine className="h-4 w-4" />
<Icon name="arrow-down" className="h-4 w-4" />
</button>
<div className="w-px h-5 bg-[var(--interactive-border)] mx-1" />
@@ -709,7 +697,7 @@ function SortableProjectItem({
onClick={onEdit}
className="p-1.5 rounded text-[var(--surface-mutedForeground)] hover:text-[var(--primary-base)] hover:bg-[var(--primary-base)]/10"
>
<RiEditLine className="h-4 w-4" />
<Icon name="edit" className="h-4 w-4" />
</button>
{/* Delete button */}
@@ -718,7 +706,7 @@ function SortableProjectItem({
onClick={onDelete}
className="p-1.5 rounded text-[var(--surface-mutedForeground)] hover:text-[var(--status-error)] hover:bg-[var(--status-error)]/10"
>
<RiDeleteBinLine className="h-4 w-4" />
<Icon name="delete-bin" className="h-4 w-4" />
</button>
</div>
</div>
@@ -863,7 +851,7 @@ function ProjectButton({
}: ProjectButtonProps) {
const { currentTheme } = useThemeSystem();
const [imageFailed, setImageFailed] = React.useState(false);
const ProjectIcon = project.icon ? PROJECT_ICON_MAP[project.icon] : null;
const projectIconName = project.icon ? PROJECT_ICON_MAP[project.icon] : null;
const projectIconImageUrl = !imageFailed
? getProjectIconImageUrl(project, {
themeVariant: currentTheme.metadata.variant,
@@ -900,7 +888,7 @@ function ProjectButton({
{/* Status indicators */}
<div className="flex items-center gap-0.5">
{status.hasRunning && (
<RiLoader4Line className="h-2.5 w-2.5 animate-spin text-[var(--status-info)]" />
<Icon name="loader-4" className="h-2.5 w-2.5 animate-spin text-[var(--status-info)]" />
)}
{!status.hasRunning && status.hasUnread && (
<div className="h-1.5 w-1.5 rounded-full bg-[var(--status-error)]" />
@@ -921,8 +909,8 @@ function ProjectButton({
onError={() => setImageFailed(true)}
/>
</span>
) : ProjectIcon && (
<ProjectIcon
) : projectIconName && (
<Icon name={projectIconName}
className="h-3.5 w-3.5"
style={projectColorVar ? { color: projectColorVar } : undefined}
/>
@@ -1021,7 +1009,7 @@ function ProjectBar({
className="flex items-center justify-center !py-1.5 px-2 rounded-md border border-[var(--primary-base)]/60 bg-[var(--primary-base)]/5 text-[var(--primary-base)]/80 hover:text-[var(--primary-base)] hover:bg-[var(--primary-base)]/10 !min-h-0"
aria-label={t('chat.mobileStatus.projects.addAria')}
>
<RiAddLine className="h-3 w-3" />
<Icon name="add" className="h-3 w-3" />
</button>
</div>
);
@@ -1097,7 +1085,7 @@ function ProjectBar({
className="flex items-center justify-center !py-1.5 px-2 rounded-md border border-[var(--primary-base)]/60 bg-[var(--primary-base)]/5 text-[var(--primary-base)]/80 hover:text-[var(--primary-base)] hover:bg-[var(--primary-base)]/10 shrink-0 !min-h-0"
aria-label={t('chat.mobileStatus.projects.addAria')}
>
<RiAddLine className="h-3.5 w-3.5" />
<Icon name="add" className="h-3.5 w-3.5" />
</button>
{/* Delete confirmation dialog */}