feat: migrate all icons to Lucide React
All checks were successful
Build & Publish / publish (push) Successful in 1m2s

Replace unicode characters, emoji, and inline SVGs with lucide-react
components across the entire design system and page layer. Update
tests to assert on SVG elements instead of text content.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
This commit is contained in:
hsiegeln
2026-03-27 23:25:43 +01:00
parent 2ffc268b44
commit 433d582da6
24 changed files with 128 additions and 123 deletions

10
package-lock.json generated
View File

@@ -8,6 +8,7 @@
"name": "@cameleer/design-system", "name": "@cameleer/design-system",
"version": "0.1.6", "version": "0.1.6",
"dependencies": { "dependencies": {
"lucide-react": "^1.7.0",
"react": "^19.0.0", "react": "^19.0.0",
"react-dom": "^19.0.0", "react-dom": "^19.0.0",
"react-router-dom": "^7.0.0" "react-router-dom": "^7.0.0"
@@ -2714,6 +2715,15 @@
"yallist": "^3.0.2" "yallist": "^3.0.2"
} }
}, },
"node_modules/lucide-react": {
"version": "1.7.0",
"resolved": "https://registry.npmjs.org/lucide-react/-/lucide-react-1.7.0.tgz",
"integrity": "sha512-yI7BeItCLZJTXikmK4KNUGCKoGzSvbKlfCvw44bU4fXAL6v3gYS4uHD1jzsLkfwODYwI6Drw5Tu9Z5ulDe0TSg==",
"license": "ISC",
"peerDependencies": {
"react": "^16.5.1 || ^17.0.0 || ^18.0.0 || ^19.0.0"
}
},
"node_modules/lz-string": { "node_modules/lz-string": {
"version": "1.5.0", "version": "1.5.0",
"resolved": "https://registry.npmjs.org/lz-string/-/lz-string-1.5.0.tgz", "resolved": "https://registry.npmjs.org/lz-string/-/lz-string-1.5.0.tgz",

View File

@@ -35,6 +35,7 @@
"test:e2e": "playwright test" "test:e2e": "playwright test"
}, },
"dependencies": { "dependencies": {
"lucide-react": "^1.7.0",
"react": "^19.0.0", "react": "^19.0.0",
"react-dom": "^19.0.0", "react-dom": "^19.0.0",
"react-router-dom": "^7.0.0" "react-router-dom": "^7.0.0"

View File

@@ -78,17 +78,16 @@ describe('AlertDialog', () => {
it('renders danger variant icon', () => { it('renders danger variant icon', () => {
render(<AlertDialog {...defaultProps} variant="danger" />) render(<AlertDialog {...defaultProps} variant="danger" />)
// Icon area should be present (aria-hidden) expect(document.querySelector('[aria-hidden="true"] svg')).toBeInTheDocument()
expect(screen.getByText('✕')).toBeInTheDocument()
}) })
it('renders warning variant icon', () => { it('renders warning variant icon', () => {
render(<AlertDialog {...defaultProps} variant="warning" />) render(<AlertDialog {...defaultProps} variant="warning" />)
expect(screen.getByText('⚠')).toBeInTheDocument() expect(document.querySelector('[aria-hidden="true"] svg')).toBeInTheDocument()
}) })
it('renders info variant icon', () => { it('renders info variant icon', () => {
render(<AlertDialog {...defaultProps} variant="info" />) render(<AlertDialog {...defaultProps} variant="info" />)
expect(screen.getByText('')).toBeInTheDocument() expect(document.querySelector('[aria-hidden="true"] svg')).toBeInTheDocument()
}) })
}) })

View File

@@ -1,4 +1,5 @@
import { useEffect, useRef } from 'react' import React, { useEffect, useRef } from 'react'
import { XCircle, AlertTriangle, Info } from 'lucide-react'
import { Modal } from '../Modal/Modal' import { Modal } from '../Modal/Modal'
import { Button } from '../../primitives/Button/Button' import { Button } from '../../primitives/Button/Button'
import styles from './AlertDialog.module.css' import styles from './AlertDialog.module.css'
@@ -16,10 +17,10 @@ interface AlertDialogProps {
className?: string className?: string
} }
const variantIcons: Record<NonNullable<AlertDialogProps['variant']>, string> = { const variantIcons: Record<NonNullable<AlertDialogProps['variant']>, React.ReactNode> = {
danger: '✕', danger: <XCircle size={20} />,
warning: '⚠', warning: <AlertTriangle size={20} />,
info: '', info: <Info size={20} />,
} }
export function AlertDialog({ export function AlertDialog({

View File

@@ -1,5 +1,6 @@
import { useState, useEffect, useRef, useMemo, type ReactNode } from 'react' import { useState, useEffect, useRef, useMemo, type ReactNode } from 'react'
import { createPortal } from 'react-dom' import { createPortal } from 'react-dom'
import { Search, X, ChevronUp, ChevronDown } from 'lucide-react'
import styles from './CommandPalette.module.css' import styles from './CommandPalette.module.css'
import { SectionHeader } from '../../primitives/SectionHeader/SectionHeader' import { SectionHeader } from '../../primitives/SectionHeader/SectionHeader'
import { CodeBlock } from '../../primitives/CodeBlock/CodeBlock' import { CodeBlock } from '../../primitives/CodeBlock/CodeBlock'
@@ -189,7 +190,7 @@ export function CommandPalette({ open, onClose, onSelect, data, onOpen, onQueryC
> >
{/* Search input area */} {/* Search input area */}
<div className={styles.searchArea}> <div className={styles.searchArea}>
<span className={styles.searchIcon} aria-hidden="true"></span> <span className={styles.searchIcon} aria-hidden="true"><Search size={14} /></span>
{scopeFilters.map((sf, i) => ( {scopeFilters.map((sf, i) => (
<span key={i} className={styles.scopeTag}> <span key={i} className={styles.scopeTag}>
<span className={styles.scopeField}>{sf.field}:</span> <span className={styles.scopeField}>{sf.field}:</span>
@@ -199,7 +200,7 @@ export function CommandPalette({ open, onClose, onSelect, data, onOpen, onQueryC
onClick={() => removeScopeFilter(i)} onClick={() => removeScopeFilter(i)}
aria-label={`Remove filter ${sf.field}:${sf.value}`} aria-label={`Remove filter ${sf.field}:${sf.value}`}
> >
× <X size={10} />
</button> </button>
</span> </span>
))} ))}
@@ -323,7 +324,7 @@ export function CommandPalette({ open, onClose, onSelect, data, onOpen, onQueryC
aria-expanded={isExpanded} aria-expanded={isExpanded}
aria-label="Toggle detail" aria-label="Toggle detail"
> >
{isExpanded ? '▲' : '▼'} {isExpanded ? <ChevronUp size={14} /> : <ChevronDown size={14} />}
</button> </button>
)} )}
</div> </div>

View File

@@ -1,4 +1,5 @@
import { type ReactNode, useEffect, useRef, useState, useCallback } from 'react' import { type ReactNode, useEffect, useRef, useState, useCallback } from 'react'
import { X as XIcon, AlertTriangle, Play, Loader } from 'lucide-react'
import styles from './EventFeed.module.css' import styles from './EventFeed.module.css'
import { ButtonGroup } from '../../primitives/ButtonGroup/ButtonGroup' import { ButtonGroup } from '../../primitives/ButtonGroup/ButtonGroup'
import type { ButtonGroupItem } from '../../primitives/ButtonGroup/ButtonGroup' import type { ButtonGroupItem } from '../../primitives/ButtonGroup/ButtonGroup'
@@ -47,11 +48,11 @@ function getSearchableText(event: FeedEvent): string {
return '' return ''
} }
const DEFAULT_ICONS: Record<SeverityFilter, string> = { const DEFAULT_ICONS: Record<SeverityFilter, ReactNode> = {
error: '\u2715', // ✕ error: <XIcon size={14} />,
warning: '\u26A0', // ⚠ warning: <AlertTriangle size={14} />,
success: '\u25B6', // ▶ success: <Play size={14} />,
running: '\u2699', // ⚙ running: <Loader size={14} />,
} }
const SEVERITY_COLORS: Record<SeverityFilter, string> = { const SEVERITY_COLORS: Record<SeverityFilter, string> = {
@@ -136,7 +137,7 @@ export function EventFeed({ events, maxItems = 200, className }: EventFeedProps)
onClick={() => setSearch('')} onClick={() => setSearch('')}
aria-label="Clear search" aria-label="Clear search"
> >
× <XIcon size={12} />
</button> </button>
)} )}
</div> </div>

View File

@@ -1,4 +1,5 @@
import { useState, type ChangeEvent } from 'react' import { useState, type ChangeEvent } from 'react'
import { Search } from 'lucide-react'
import styles from './FilterBar.module.css' import styles from './FilterBar.module.css'
import { Input } from '../../primitives/Input/Input' import { Input } from '../../primitives/Input/Input'
import { FilterPill } from '../../primitives/FilterPill/FilterPill' import { FilterPill } from '../../primitives/FilterPill/FilterPill'
@@ -77,12 +78,7 @@ export function FilterBar({
if (onSearchChange) onSearchChange('') if (onSearchChange) onSearchChange('')
else setInternalSearch('') else setInternalSearch('')
} : undefined} } : undefined}
icon={ icon={<Search size={13} />}
<svg width="13" height="13" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round">
<circle cx="11" cy="11" r="8" />
<line x1="21" y1="21" x2="16.65" y2="16.65" />
</svg>
}
/> />
</div> </div>

View File

@@ -1,11 +1,11 @@
import styles from './KpiStrip.module.css' import styles from './KpiStrip.module.css'
import { Sparkline } from '../../primitives/Sparkline/Sparkline' import { Sparkline } from '../../primitives/Sparkline/Sparkline'
import type { CSSProperties } from 'react' import type { CSSProperties, ReactNode } from 'react'
export interface KpiItem { export interface KpiItem {
label: string label: string
value: string | number value: string | number
trend?: { label: string; variant?: 'success' | 'warning' | 'error' | 'muted' } trend?: { label: ReactNode; variant?: 'success' | 'warning' | 'error' | 'muted' }
subtitle?: string subtitle?: string
sparkline?: number[] sparkline?: number[]
borderColor?: string borderColor?: string

View File

@@ -1,4 +1,5 @@
import type { ReactNode } from 'react' import type { ReactNode } from 'react'
import { EllipsisVertical } from 'lucide-react'
import styles from './ProcessorTimeline.module.css' import styles from './ProcessorTimeline.module.css'
import { Dropdown } from '../Dropdown/Dropdown' import { Dropdown } from '../Dropdown/Dropdown'
import type { NodeBadge } from '../RouteFlow/RouteFlow' import type { NodeBadge } from '../RouteFlow/RouteFlow'
@@ -124,7 +125,7 @@ export function ProcessorTimeline({
aria-label={`Actions for ${proc.name}`} aria-label={`Actions for ${proc.name}`}
type="button" type="button"
> >
<EllipsisVertical size={14} />
</button> </button>
} }
items={resolvedActions} items={resolvedActions}

View File

@@ -1,4 +1,5 @@
import type { ReactNode } from 'react' import type { ReactNode } from 'react'
import { Play, Cog, Square, Diamond, AlertTriangle, EllipsisVertical } from 'lucide-react'
import styles from './RouteFlow.module.css' import styles from './RouteFlow.module.css'
import { Dropdown } from '../Dropdown/Dropdown' import { Dropdown } from '../Dropdown/Dropdown'
@@ -55,12 +56,12 @@ function durationClass(ms: number, status: string): string {
return styles.durBreach return styles.durBreach
} }
const TYPE_ICONS: Record<string, string> = { const TYPE_ICONS: Record<string, ReactNode> = {
'from': '\u25B6', 'from': <Play size={14} />,
'process': '\u2699', 'process': <Cog size={14} />,
'to': '\u25A2', 'to': <Square size={14} />,
'choice': '\u25C6', 'choice': <Diamond size={14} />,
'error-handler': '\u26A0', 'error-handler': <AlertTriangle size={14} />,
} }
const ICON_CLASSES: Record<string, string> = { const ICON_CLASSES: Record<string, string> = {
@@ -100,7 +101,7 @@ function renderActionTrigger(
aria-label={`Actions for ${node.name}`} aria-label={`Actions for ${node.name}`}
type="button" type="button"
> >
<EllipsisVertical size={14} />
</button> </button>
} }
items={resolvedActions} items={resolvedActions}
@@ -166,7 +167,7 @@ function renderNodeChain(
</span> </span>
) : null} ) : null}
<div className={`${styles.icon} ${ICON_CLASSES[node.type] ?? styles.iconTo}`}> <div className={`${styles.icon} ${ICON_CLASSES[node.type] ?? styles.iconTo}`}>
{TYPE_ICONS[node.type] ?? '\u25A2'} {TYPE_ICONS[node.type] ?? <Square size={14} />}
</div> </div>
<div className={styles.info}> <div className={styles.info}>
<div className={styles.type}>{node.type}</div> <div className={styles.type}>{node.type}</div>
@@ -260,7 +261,7 @@ export function RouteFlow({ nodes, flows, onNodeClick, selectedIndex, actions, g
</span> </span>
) : null} ) : null}
<div className={`${styles.icon} ${ICON_CLASSES[node.type] ?? styles.iconTo}`}> <div className={`${styles.icon} ${ICON_CLASSES[node.type] ?? styles.iconTo}`}>
{TYPE_ICONS[node.type] ?? '\u25A2'} {TYPE_ICONS[node.type] ?? <Square size={14} />}
</div> </div>
<div className={styles.info}> <div className={styles.info}>
<div className={styles.type}>{node.type}</div> <div className={styles.type}>{node.type}</div>

View File

@@ -89,7 +89,7 @@ describe('Toast', () => {
act(() => { getApi().toast({ title: 'Info', variant: 'info' }) }) act(() => { getApi().toast({ title: 'Info', variant: 'info' }) })
expect(screen.getByText('')).toBeInTheDocument() expect(screen.getByTestId('toast').querySelector('[aria-hidden="true"] svg')).toBeInTheDocument()
}) })
it('shows correct icon for success variant', () => { it('shows correct icon for success variant', () => {
@@ -97,7 +97,7 @@ describe('Toast', () => {
act(() => { getApi().toast({ title: 'OK', variant: 'success' }) }) act(() => { getApi().toast({ title: 'OK', variant: 'success' }) })
expect(screen.getByText('')).toBeInTheDocument() expect(screen.getByTestId('toast').querySelector('[aria-hidden="true"] svg')).toBeInTheDocument()
}) })
it('shows correct icon for warning variant', () => { it('shows correct icon for warning variant', () => {
@@ -105,7 +105,7 @@ describe('Toast', () => {
act(() => { getApi().toast({ title: 'Warn', variant: 'warning' }) }) act(() => { getApi().toast({ title: 'Warn', variant: 'warning' }) })
expect(screen.getByText('')).toBeInTheDocument() expect(screen.getByTestId('toast').querySelector('[aria-hidden="true"] svg')).toBeInTheDocument()
}) })
it('shows correct icon for error variant', () => { it('shows correct icon for error variant', () => {
@@ -113,7 +113,7 @@ describe('Toast', () => {
act(() => { getApi().toast({ title: 'Err', variant: 'error' }) }) act(() => { getApi().toast({ title: 'Err', variant: 'error' }) })
expect(screen.getByText('')).toBeInTheDocument() expect(screen.getByTestId('toast').querySelector('[aria-hidden="true"] svg')).toBeInTheDocument()
}) })
it('dismisses toast when close button is clicked', () => { it('dismisses toast when close button is clicked', () => {

View File

@@ -8,6 +8,7 @@ import {
type ReactNode, type ReactNode,
} from 'react' } from 'react'
import { createPortal } from 'react-dom' import { createPortal } from 'react-dom'
import { Info, CheckCircle, AlertTriangle, XCircle, X } from 'lucide-react'
import styles from './Toast.module.css' import styles from './Toast.module.css'
// ── Types ────────────────────────────────────────────────────────────────── // ── Types ──────────────────────────────────────────────────────────────────
@@ -39,11 +40,11 @@ const MAX_TOASTS = 5
const DEFAULT_DURATION = 5000 const DEFAULT_DURATION = 5000
const EXIT_ANIMATION_MS = 300 const EXIT_ANIMATION_MS = 300
const ICONS: Record<ToastVariant, string> = { const ICONS: Record<ToastVariant, ReactNode> = {
info: '', info: <Info size={16} />,
success: '✓', success: <CheckCircle size={16} />,
warning: '⚠', warning: <AlertTriangle size={16} />,
error: '✕', error: <XCircle size={16} />,
} }
// ── Context ──────────────────────────────────────────────────────────────── // ── Context ────────────────────────────────────────────────────────────────
@@ -183,7 +184,7 @@ function ToastItemComponent({ toast, onDismiss }: ToastItemComponentProps) {
aria-label="Dismiss notification" aria-label="Dismiss notification"
type="button" type="button"
> >
&times; <X size={14} />
</button> </button>
</div> </div>
) )

View File

@@ -1,5 +1,6 @@
import { useState, useEffect, useMemo } from 'react' import { useState, useEffect, useMemo } from 'react'
import { useNavigate, useLocation } from 'react-router-dom' import { useNavigate, useLocation } from 'react-router-dom'
import { Search, X, ChevronRight, ChevronDown, Settings, FileText } from 'lucide-react'
import styles from './Sidebar.module.css' import styles from './Sidebar.module.css'
import camelLogoUrl from '../../../assets/camel-logo.svg' import camelLogoUrl from '../../../assets/camel-logo.svg'
import { SidebarTree, type SidebarTreeNode } from './SidebarTree' import { SidebarTree, type SidebarTreeNode } from './SidebarTree'
@@ -55,7 +56,7 @@ function buildAppTreeNodes(apps: SidebarApp[]): SidebarTreeNode[] {
id: `route:${app.id}:${route.id}`, id: `route:${app.id}:${route.id}`,
starKey: `${app.id}:${route.id}`, starKey: `${app.id}:${route.id}`,
label: route.name, label: route.name,
icon: <span className={styles.routeArrow}>&#9656;</span>, icon: <ChevronRight size={12} />,
badge: formatCount(route.exchangeCount), badge: formatCount(route.exchangeCount),
path: `/apps/${app.id}/${route.id}`, path: `/apps/${app.id}/${route.id}`,
starrable: true, starrable: true,
@@ -78,7 +79,7 @@ function buildRouteTreeNodes(apps: SidebarApp[]): SidebarTreeNode[] {
id: `routestat:${app.id}:${route.id}`, id: `routestat:${app.id}:${route.id}`,
starKey: `routes:${app.id}:${route.id}`, starKey: `routes:${app.id}:${route.id}`,
label: route.name, label: route.name,
icon: <span className={styles.routeArrow}>&#9656;</span>, icon: <ChevronRight size={12} />,
badge: formatCount(route.exchangeCount), badge: formatCount(route.exchangeCount),
path: `/routes/${app.id}/${route.id}`, path: `/routes/${app.id}/${route.id}`,
starrable: true, starrable: true,
@@ -235,10 +236,7 @@ function StarredGroup({
tabIndex={-1} tabIndex={-1}
aria-label={`Remove ${item.label} from starred`} aria-label={`Remove ${item.label} from starred`}
> >
<svg width="12" height="12" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2"> <X size={12} />
<line x1="18" y1="6" x2="6" y2="18" />
<line x1="6" y1="6" x2="18" y2="18" />
</svg>
</button> </button>
</div> </div>
))} ))}
@@ -344,10 +342,7 @@ export function Sidebar({ apps, className }: SidebarProps) {
<div className={styles.searchWrap}> <div className={styles.searchWrap}>
<div className={styles.searchInner}> <div className={styles.searchInner}>
<span className={styles.searchIcon} aria-hidden="true"> <span className={styles.searchIcon} aria-hidden="true">
<svg width="12" height="12" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round"> <Search size={12} />
<circle cx="11" cy="11" r="8" />
<line x1="21" y1="21" x2="16.65" y2="16.65" />
</svg>
</span> </span>
<input <input
className={styles.searchInput} className={styles.searchInput}
@@ -363,7 +358,7 @@ export function Sidebar({ apps, className }: SidebarProps) {
onClick={() => setSearch('')} onClick={() => setSearch('')}
aria-label="Clear search" aria-label="Clear search"
> >
× <X size={12} />
</button> </button>
)} )}
</div> </div>
@@ -382,7 +377,7 @@ export function Sidebar({ apps, className }: SidebarProps) {
aria-expanded={!appsCollapsed} aria-expanded={!appsCollapsed}
aria-label={appsCollapsed ? 'Expand Applications' : 'Collapse Applications'} aria-label={appsCollapsed ? 'Expand Applications' : 'Collapse Applications'}
> >
{appsCollapsed ? '▸' : '▾'} {appsCollapsed ? <ChevronRight size={12} /> : <ChevronDown size={12} />}
</button> </button>
<span <span
className={`${styles.treeSectionLabel} ${location.pathname === '/apps' ? styles.treeSectionLabelActive : ''}`} className={`${styles.treeSectionLabel} ${location.pathname === '/apps' ? styles.treeSectionLabelActive : ''}`}
@@ -416,7 +411,7 @@ export function Sidebar({ apps, className }: SidebarProps) {
aria-expanded={!agentsCollapsed} aria-expanded={!agentsCollapsed}
aria-label={agentsCollapsed ? 'Expand Agents' : 'Collapse Agents'} aria-label={agentsCollapsed ? 'Expand Agents' : 'Collapse Agents'}
> >
{agentsCollapsed ? '▸' : '▾'} {agentsCollapsed ? <ChevronRight size={12} /> : <ChevronDown size={12} />}
</button> </button>
<span <span
className={`${styles.treeSectionLabel} ${location.pathname.startsWith('/agents') ? styles.treeSectionLabelActive : ''}`} className={`${styles.treeSectionLabel} ${location.pathname.startsWith('/agents') ? styles.treeSectionLabelActive : ''}`}
@@ -450,7 +445,7 @@ export function Sidebar({ apps, className }: SidebarProps) {
aria-expanded={!routesCollapsed} aria-expanded={!routesCollapsed}
aria-label={routesCollapsed ? 'Expand Routes' : 'Collapse Routes'} aria-label={routesCollapsed ? 'Expand Routes' : 'Collapse Routes'}
> >
{routesCollapsed ? '▸' : '▾'} {routesCollapsed ? <ChevronRight size={12} /> : <ChevronDown size={12} />}
</button> </button>
<span <span
className={`${styles.treeSectionLabel} ${location.pathname === '/routes' ? styles.treeSectionLabelActive : ''}`} className={`${styles.treeSectionLabel} ${location.pathname === '/routes' ? styles.treeSectionLabelActive : ''}`}
@@ -536,7 +531,7 @@ export function Sidebar({ apps, className }: SidebarProps) {
tabIndex={0} tabIndex={0}
onKeyDown={(e) => { if (e.key === 'Enter' || e.key === ' ') navigate('/admin') }} onKeyDown={(e) => { if (e.key === 'Enter' || e.key === ' ') navigate('/admin') }}
> >
<span className={styles.bottomIcon}>&#9881;</span> <span className={styles.bottomIcon}><Settings size={14} /></span>
<div className={styles.itemInfo}> <div className={styles.itemInfo}>
<div className={styles.itemName}>Admin</div> <div className={styles.itemName}>Admin</div>
</div> </div>
@@ -551,7 +546,7 @@ export function Sidebar({ apps, className }: SidebarProps) {
tabIndex={0} tabIndex={0}
onKeyDown={(e) => { if (e.key === 'Enter' || e.key === ' ') navigate('/api-docs') }} onKeyDown={(e) => { if (e.key === 'Enter' || e.key === ' ') navigate('/api-docs') }}
> >
<span className={styles.bottomIcon}>&#9776;</span> <span className={styles.bottomIcon}><FileText size={14} /></span>
<div className={styles.itemInfo}> <div className={styles.itemInfo}>
<div className={styles.itemName}>API Docs</div> <div className={styles.itemName}>API Docs</div>
</div> </div>

View File

@@ -9,6 +9,7 @@ import {
type MouseEvent, type MouseEvent,
} from 'react' } from 'react'
import { useNavigate } from 'react-router-dom' import { useNavigate } from 'react-router-dom'
import { Star, ChevronRight, ChevronDown } from 'lucide-react'
import styles from './Sidebar.module.css' import styles from './Sidebar.module.css'
// ── Types ──────────────────────────────────────────────────────────────────── // ── Types ────────────────────────────────────────────────────────────────────
@@ -35,22 +36,14 @@ export interface SidebarTreeProps {
autoRevealPath?: string | null // when set, auto-expand the parent of the matching node autoRevealPath?: string | null // when set, auto-expand the parent of the matching node
} }
// ── Star icon SVGs ─────────────────────────────────────────────────────────── // ── Star icons ───────────────────────────────────────────────────────────────
function StarOutline() { function StarOutline() {
return ( return <Star size={14} />
<svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2">
<path d="M12 2l3.09 6.26L22 9.27l-5 4.87 1.18 6.88L12 17.77l-6.18 3.25L7 14.14 2 9.27l6.91-1.01L12 2z" />
</svg>
)
} }
function StarFilled() { function StarFilled() {
return ( return <Star size={14} fill="currentColor" />
<svg width="14" height="14" viewBox="0 0 24 24" fill="currentColor" stroke="currentColor" strokeWidth="2">
<path d="M12 2l3.09 6.26L22 9.27l-5 4.87 1.18 6.88L12 17.77l-6.18 3.25L7 14.14 2 9.27l6.91-1.01L12 2z" />
</svg>
)
} }
// ── Persistent expand state ────────────────────────────────────────────────── // ── Persistent expand state ──────────────────────────────────────────────────
@@ -395,7 +388,7 @@ function SidebarTreeRow({
tabIndex={-1} tabIndex={-1}
aria-label={isExpanded ? 'Collapse' : 'Expand'} aria-label={isExpanded ? 'Collapse' : 'Expand'}
> >
{isExpanded ? '▾' : '▸'} {isExpanded ? <ChevronDown size={12} /> : <ChevronRight size={12} />}
</button> </button>
) : null} ) : null}
</span> </span>

View File

@@ -1,3 +1,4 @@
import { Search, Moon, Sun, Power } from 'lucide-react'
import styles from './TopBar.module.css' import styles from './TopBar.module.css'
import { Breadcrumb } from '../../composites/Breadcrumb/Breadcrumb' import { Breadcrumb } from '../../composites/Breadcrumb/Breadcrumb'
import { Dropdown } from '../../composites/Dropdown/Dropdown' import { Dropdown } from '../../composites/Dropdown/Dropdown'
@@ -51,10 +52,7 @@ export function TopBar({
aria-label="Open search" aria-label="Open search"
> >
<span className={styles.searchIcon} aria-hidden="true"> <span className={styles.searchIcon} aria-hidden="true">
<svg width="13" height="13" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round"> <Search size={13} />
<circle cx="11" cy="11" r="8" />
<line x1="21" y1="21" x2="16.65" y2="16.65" />
</svg>
</span> </span>
<span className={styles.searchPlaceholder}>Search... &#8984;K</span> <span className={styles.searchPlaceholder}>Search... &#8984;K</span>
<span className={styles.kbd}>Ctrl+K</span> <span className={styles.kbd}>Ctrl+K</span>
@@ -101,7 +99,7 @@ export function TopBar({
aria-label={`Switch to ${theme === 'light' ? 'dark' : 'light'} mode`} aria-label={`Switch to ${theme === 'light' ? 'dark' : 'light'} mode`}
title={`Switch to ${theme === 'light' ? 'dark' : 'light'} mode`} title={`Switch to ${theme === 'light' ? 'dark' : 'light'} mode`}
> >
{theme === 'light' ? '\u263E' : '\u2600'} {theme === 'light' ? <Moon size={16} /> : <Sun size={16} />}
</button> </button>
{environment && ( {environment && (
<span className={styles.env}>{environment}</span> <span className={styles.env}>{environment}</span>
@@ -115,7 +113,7 @@ export function TopBar({
</div> </div>
} }
items={[ items={[
{ label: 'Logout', icon: '\u23FB', onClick: onLogout }, { label: 'Logout', icon: <Power size={14} />, onClick: onLogout },
]} ]}
/> />
)} )}

View File

@@ -46,24 +46,23 @@ describe('Alert', () => {
}) })
it('shows default icon for each variant', () => { it('shows default icon for each variant', () => {
const { rerender } = render(<Alert variant="info">msg</Alert>) const { container, rerender } = render(<Alert variant="info">msg</Alert>)
expect(screen.getByText('')).toBeInTheDocument() // Each variant should render an SVG icon in the icon slot
expect(container.querySelector('[aria-hidden="true"] svg')).toBeInTheDocument()
rerender(<Alert variant="success">msg</Alert>) rerender(<Alert variant="success">msg</Alert>)
expect(screen.getByText('✓')).toBeInTheDocument() expect(container.querySelector('[aria-hidden="true"] svg')).toBeInTheDocument()
rerender(<Alert variant="warning">msg</Alert>) rerender(<Alert variant="warning">msg</Alert>)
expect(screen.getByText('⚠')).toBeInTheDocument() expect(container.querySelector('[aria-hidden="true"] svg')).toBeInTheDocument()
rerender(<Alert variant="error">msg</Alert>) rerender(<Alert variant="error">msg</Alert>)
expect(screen.getByText('✕')).toBeInTheDocument() expect(container.querySelector('[aria-hidden="true"] svg')).toBeInTheDocument()
}) })
it('renders a custom icon when provided', () => { it('renders a custom icon when provided', () => {
render(<Alert icon={<span></span>}>Custom icon alert</Alert>) render(<Alert icon={<span data-testid="custom-icon"></span>}>Custom icon alert</Alert>)
expect(screen.getByText('')).toBeInTheDocument() expect(screen.getByTestId('custom-icon')).toBeInTheDocument()
// Default icon should not appear
expect(screen.queryByText('')).not.toBeInTheDocument()
}) })
it('does not show dismiss button when dismissible is false', () => { it('does not show dismiss button when dismissible is false', () => {

View File

@@ -1,4 +1,5 @@
import { ReactNode } from 'react' import { ReactNode } from 'react'
import { Info, CheckCircle, AlertTriangle, XCircle, X } from 'lucide-react'
import styles from './Alert.module.css' import styles from './Alert.module.css'
type AlertVariant = 'info' | 'success' | 'warning' | 'error' type AlertVariant = 'info' | 'success' | 'warning' | 'error'
@@ -13,11 +14,11 @@ interface AlertProps {
className?: string className?: string
} }
const DEFAULT_ICONS: Record<AlertVariant, string> = { const DEFAULT_ICONS: Record<AlertVariant, ReactNode> = {
info: '', info: <Info size={16} />,
success: '✓', success: <CheckCircle size={16} />,
warning: '⚠', warning: <AlertTriangle size={16} />,
error: '✕', error: <XCircle size={16} />,
} }
const ARIA_ROLES: Record<AlertVariant, 'alert' | 'status'> = { const ARIA_ROLES: Record<AlertVariant, 'alert' | 'status'> = {
@@ -61,7 +62,7 @@ export function Alert({
aria-label="Dismiss alert" aria-label="Dismiss alert"
type="button" type="button"
> >
&times; <X size={14} />
</button> </button>
)} )}
</div> </div>

View File

@@ -1,5 +1,6 @@
import styles from './Input.module.css' import styles from './Input.module.css'
import { forwardRef, type InputHTMLAttributes, type ReactNode } from 'react' import { forwardRef, type InputHTMLAttributes, type ReactNode } from 'react'
import { X } from 'lucide-react'
interface InputProps extends InputHTMLAttributes<HTMLInputElement> { interface InputProps extends InputHTMLAttributes<HTMLInputElement> {
icon?: ReactNode icon?: ReactNode
@@ -25,7 +26,7 @@ export const Input = forwardRef<HTMLInputElement, InputProps>(
onClick={onClear} onClick={onClear}
aria-label="Clear search" aria-label="Clear search"
> >
× <X size={12} />
</button> </button>
)} )}
</div> </div>

View File

@@ -1,5 +1,6 @@
import { useState, useMemo } from 'react' import { useState, useMemo } from 'react'
import { useParams, Link } from 'react-router-dom' import { useParams, Link } from 'react-router-dom'
import { ChevronRight } from 'lucide-react'
import styles from './AgentHealth.module.css' import styles from './AgentHealth.module.css'
// Layout // Layout
@@ -389,7 +390,7 @@ export function AgentHealth() {
{scope.level !== 'all' && ( {scope.level !== 'all' && (
<> <>
<Link to="/agents" className={styles.scopeLink}>All Agents</Link> <Link to="/agents" className={styles.scopeLink}>All Agents</Link>
<span className={styles.scopeSep}>&#9656;</span> <span className={styles.scopeSep}><ChevronRight size={12} /></span>
<span className={styles.scopeCurrent}>{scope.appId}</span> <span className={styles.scopeCurrent}>{scope.appId}</span>
</> </>
)} )}

View File

@@ -1,5 +1,6 @@
import { useMemo } from 'react' import { useMemo } from 'react'
import { useParams, Link } from 'react-router-dom' import { useParams, Link } from 'react-router-dom'
import { ChevronRight } from 'lucide-react'
import styles from './AgentInstance.module.css' import styles from './AgentInstance.module.css'
// Layout // Layout
@@ -177,9 +178,9 @@ export function AgentInstance() {
{/* Scope trail + badges */} {/* Scope trail + badges */}
<div className={styles.scopeTrail}> <div className={styles.scopeTrail}>
<Link to="/agents" className={styles.scopeLink}>All Agents</Link> <Link to="/agents" className={styles.scopeLink}>All Agents</Link>
<span className={styles.scopeSep}>&#9656;</span> <span className={styles.scopeSep}><ChevronRight size={12} /></span>
<Link to={`/agents/${appId}`} className={styles.scopeLink}>{appId}</Link> <Link to={`/agents/${appId}`} className={styles.scopeLink}>{appId}</Link>
<span className={styles.scopeSep}>&#9656;</span> <span className={styles.scopeSep}><ChevronRight size={12} /></span>
<span className={styles.scopeCurrent}>{agent.name}</span> <span className={styles.scopeCurrent}>{agent.name}</span>
<Badge label={agent.status.toUpperCase()} color={statusColor} /> <Badge label={agent.status.toUpperCase()} color={statusColor} />
<Badge label={agent.version} color="auto" variant="outlined" /> <Badge label={agent.version} color="auto" variant="outlined" />

View File

@@ -1,5 +1,6 @@
import { useState, useMemo } from 'react' import React, { useState, useMemo } from 'react'
import { useParams, useNavigate } from 'react-router-dom' import { useParams, useNavigate } from 'react-router-dom'
import { TrendingUp, TrendingDown, ArrowRight, ExternalLink, AlertTriangle } from 'lucide-react'
import styles from './Dashboard.module.css' import styles from './Dashboard.module.css'
// Layout // Layout
@@ -43,10 +44,10 @@ const ACCENT_TO_COLOR: Record<KpiMetric['accent'], string> = {
warning: 'var(--warning)', warning: 'var(--warning)',
} }
const TREND_ICONS: Record<KpiMetric['trend'], string> = { const TREND_ICONS: Record<KpiMetric['trend'], React.ReactNode> = {
up: '\u2191', up: <TrendingUp size={12} />,
down: '\u2193', down: <TrendingDown size={12} />,
neutral: '\u2192', neutral: <ArrowRight size={12} />,
} }
function sentimentToVariant(sentiment: KpiMetric['trendSentiment']): 'success' | 'error' | 'muted' { function sentimentToVariant(sentiment: KpiMetric['trendSentiment']): 'success' | 'error' | 'muted' {
@@ -60,7 +61,7 @@ function sentimentToVariant(sentiment: KpiMetric['trendSentiment']): 'success' |
const kpiItems: KpiItem[] = kpiMetrics.map((m) => ({ const kpiItems: KpiItem[] = kpiMetrics.map((m) => ({
label: m.label, label: m.label,
value: m.unit ? `${m.value} ${m.unit}` : m.value, value: m.unit ? `${m.value} ${m.unit}` : m.value,
trend: { label: `${TREND_ICONS[m.trend]} ${m.trendValue}`, variant: sentimentToVariant(m.trendSentiment) }, trend: { label: <><span style={{ display: 'inline-flex', verticalAlign: 'middle' }}>{TREND_ICONS[m.trend]}</span> {m.trendValue}</>, variant: sentimentToVariant(m.trendSentiment) },
subtitle: m.detail, subtitle: m.detail,
sparkline: m.sparkline, sparkline: m.sparkline,
borderColor: ACCENT_TO_COLOR[m.accent], borderColor: ACCENT_TO_COLOR[m.accent],
@@ -206,7 +207,7 @@ export function Dashboard() {
navigate(`/exchanges/${row.id}`) navigate(`/exchanges/${row.id}`)
}} }}
> >
&#x2197; <ExternalLink size={14} />
</button> </button>
), ),
} }
@@ -303,7 +304,7 @@ export function Dashboard() {
className={styles.openDetailLink} className={styles.openDetailLink}
onClick={() => navigate(`/exchanges/${selectedExchange.id}`)} onClick={() => navigate(`/exchanges/${selectedExchange.id}`)}
> >
Open full details &#x2192; Open full details <ArrowRight size={14} style={{ verticalAlign: 'middle' }} />
</button> </button>
</div> </div>
@@ -428,7 +429,7 @@ export function Dashboard() {
expandedContent={(row) => expandedContent={(row) =>
row.errorMessage ? ( row.errorMessage ? (
<div className={styles.inlineError}> <div className={styles.inlineError}>
<span className={styles.inlineErrorIcon}></span> <span className={styles.inlineErrorIcon}><AlertTriangle size={14} /></span>
<div> <div>
<div className={styles.inlineErrorText}>{row.errorMessage}</div> <div className={styles.inlineErrorText}>{row.errorMessage}</div>
<div className={styles.inlineErrorHint}>Click to view full stack trace</div> <div className={styles.inlineErrorHint}>Click to view full stack trace</div>

View File

@@ -1,4 +1,5 @@
import { useState } from 'react' import { useState } from 'react'
import { Hexagon, ArrowRight, Diamond, Eye, Pencil, RotateCcw, Trash2, ChevronDown } from 'lucide-react'
import styles from './CompositesSection.module.css' import styles from './CompositesSection.module.css'
import { import {
Accordion, Accordion,
@@ -157,25 +158,25 @@ const TREE_NODES = [
{ {
id: 'app1', id: 'app1',
label: 'cameleer-prod', label: 'cameleer-prod',
icon: '⬡', icon: <Hexagon size={14} />,
children: [ children: [
{ {
id: 'route1', id: 'route1',
label: 'order-ingest', label: 'order-ingest',
icon: '→', icon: <ArrowRight size={14} />,
children: [ children: [
{ id: 'proc1', label: 'ValidateOrder', icon: '◈', meta: '12ms' }, { id: 'proc1', label: 'ValidateOrder', icon: <Diamond size={12} />, meta: '12ms' },
{ id: 'proc2', label: 'EnrichPayload', icon: '◈', meta: '8ms' }, { id: 'proc2', label: 'EnrichPayload', icon: <Diamond size={12} />, meta: '8ms' },
{ id: 'proc3', label: 'RouteToQueue', icon: '◈', meta: '3ms' }, { id: 'proc3', label: 'RouteToQueue', icon: <Diamond size={12} />, meta: '3ms' },
], ],
}, },
{ {
id: 'route2', id: 'route2',
label: 'payment-validate', label: 'payment-validate',
icon: '→', icon: <ArrowRight size={14} />,
children: [ children: [
{ id: 'proc4', label: 'TokenizeCard', icon: '◈', meta: '22ms' }, { id: 'proc4', label: 'TokenizeCard', icon: <Diamond size={12} />, meta: '22ms' },
{ id: 'proc5', label: 'AuthorizePayment', icon: '◈', meta: '45ms' }, { id: 'proc5', label: 'AuthorizePayment', icon: <Diamond size={12} />, meta: '45ms' },
], ],
}, },
], ],
@@ -505,13 +506,13 @@ export function CompositesSection() {
description="Click-triggered dropdown menu with icons, dividers, and disabled items." description="Click-triggered dropdown menu with icons, dividers, and disabled items."
> >
<Dropdown <Dropdown
trigger={<Button size="sm" variant="secondary">Actions </Button>} trigger={<Button size="sm" variant="secondary">Actions <ChevronDown size={12} /></Button>}
items={[ items={[
{ label: 'View details', icon: '👁', onClick: () => undefined }, { label: 'View details', icon: <Eye size={14} />, onClick: () => undefined },
{ label: 'Edit route', icon: '✏', onClick: () => undefined }, { label: 'Edit route', icon: <Pencil size={14} />, onClick: () => undefined },
{ divider: true, label: '' }, { divider: true, label: '' },
{ label: 'Restart', icon: '↺', onClick: () => undefined }, { label: 'Restart', icon: <RotateCcw size={14} />, onClick: () => undefined },
{ label: 'Delete', icon: '✕', onClick: () => undefined, disabled: true }, { label: 'Delete', icon: <Trash2 size={14} />, onClick: () => undefined, disabled: true },
]} ]}
/> />
</DemoCard> </DemoCard>

View File

@@ -1,4 +1,5 @@
import { useState } from 'react' import { useState } from 'react'
import { Search } from 'lucide-react'
import styles from './PrimitivesSection.module.css' import styles from './PrimitivesSection.module.css'
import { import {
Alert, Alert,
@@ -358,7 +359,7 @@ export function PrimitivesSection() {
description="Text input with optional leading icon and placeholder." description="Text input with optional leading icon and placeholder."
> >
<Input placeholder="Plain input" /> <Input placeholder="Plain input" />
<Input icon="🔍" placeholder="With icon" /> <Input icon={<Search size={14} />} placeholder="With icon" />
</DemoCard> </DemoCard>
{/* 15b. InlineEdit */} {/* 15b. InlineEdit */}

View File

@@ -1,5 +1,6 @@
import { useMemo } from 'react' import { useMemo } from 'react'
import { useParams, useNavigate } from 'react-router-dom' import { useParams, useNavigate } from 'react-router-dom'
import { AlertTriangle } from 'lucide-react'
import styles from './RouteDetail.module.css' import styles from './RouteDetail.module.css'
// Layout // Layout
@@ -337,7 +338,7 @@ export function RouteDetail() {
expandedContent={(row) => expandedContent={(row) =>
row.errorMessage ? ( row.errorMessage ? (
<div className={styles.inlineError}> <div className={styles.inlineError}>
<span className={styles.inlineErrorIcon}></span> <span className={styles.inlineErrorIcon}><AlertTriangle size={14} /></span>
<div> <div>
<div className={styles.errorClass}>{row.errorClass}</div> <div className={styles.errorClass}>{row.errorClass}</div>
<div className={styles.errorText}>{row.errorMessage}</div> <div className={styles.errorText}>{row.errorMessage}</div>