# Charon UI/UX Improvement Plan **Issue:** GitHub #409 - UI Enhancement & Design System **Date:** December 16, 2025 **Status:** ✅ Completed **Completion Date:** December 16, 2025 **Stack:** React 19 + Vite + TypeScript + TanStack Query + Tailwind CSS v4 --- ## Executive Summary The current Charon UI is functional but lacks design consistency, visual polish, and systematic component architecture. This plan addresses Issue #409's recommendations to transform the interface from "bland" to professional-grade through: 1. **Design Token System** - Consistent colors, spacing, typography 2. **Component Library** - Reusable, accessible UI primitives 3. **Layout Improvements** - Better dashboards, tables, empty states 4. **Page Polish** - Systematic improvement of all pages --- ## 1. Current State Analysis ### 1.1 Tailwind Configuration (tailwind.config.js) **Current:** ```javascript colors: { 'light-bg': '#f0f4f8', 'dark-bg': '#0f172a', 'dark-sidebar': '#020617', 'dark-card': '#1e293b', 'blue-active': '#1d4ed8', 'blue-hover': '#2563eb', } ``` **Problems:** - ❌ Only 6 ad-hoc color tokens - ❌ No semantic naming (surface, border, text layers) - ❌ No state colors (success, warning, error, info) - ❌ No brand color scale - ❌ No spacing scale beyond Tailwind defaults - ❌ No typography configuration ### 1.2 CSS Variables (index.css) **Current:** ```css :root { font-family: Inter, system-ui, Avenir, Helvetica, Arial, sans-serif; color: rgba(255, 255, 255, 0.87); background-color: #0f172a; } ``` **Problems:** - ❌ Hardcoded colors, not CSS variables - ❌ No dark/light mode toggle system - ❌ No type scale - ❌ Custom animations exist but no transition standards ### 1.3 Existing Component Library (frontend/src/components/ui/) | Component | Status | Issues | |-----------|--------|--------| | `Button.tsx` | ✅ Good foundation | Missing outline variant, icon support | | `Card.tsx` | ✅ Good foundation | Missing hover states, compact variant | | `Input.tsx` | ✅ Good foundation | No textarea, select variants | | `Switch.tsx` | ⚠️ Functional | Hard-coded colors, no size variants | **Missing Components:** - Badge/Tag - Alert/Callout - Dialog/Modal (exists ad-hoc in pages) - Dropdown/Select - Tabs - Tooltip - Table (data table with sorting) - Skeleton loaders - Progress indicators ### 1.4 Page-Level UI Patterns | Page | Patterns | Issues | |------|----------|--------| | Dashboard | KPI cards, links | Cards lack visual hierarchy, no trend indicators | | ProxyHosts | Data table, modals | Inline modals, inconsistent styling, no sticky headers | | Security | Layer cards, toggles | Good theming, but cards cramped | | Settings | Tab navigation, forms | Basic tabs, form styling inconsistent | | AccessLists | Table with selection | Good patterns, inline confirm dialogs | ### 1.5 Inconsistencies Found 1. **Modal Patterns**: Some use `fixed inset-0`, some use custom positioning 2. **Button Styling**: Mix of `bg-blue-active` and `bg-blue-600` 3. **Card Borders**: Some use `border-gray-800`, others `border-gray-700` 4. **Text Colors**: Inconsistent use of gray scale (gray-400/500 for secondary) 5. **Spacing**: No consistent page gutters or section spacing 6. **Focus States**: `focus:ring-2` used but not consistently 7. **Loading States**: Custom Charon/Cerberus loaders exist but not used everywhere --- ## 2. Design Token System ### 2.1 CSS Variables (index.css) ```css @layer base { :root { /* ======================================== * BRAND COLORS * ======================================== */ --color-brand-50: 239 246 255; /* #eff6ff */ --color-brand-100: 219 234 254; /* #dbeafe */ --color-brand-200: 191 219 254; /* #bfdbfe */ --color-brand-300: 147 197 253; /* #93c5fd */ --color-brand-400: 96 165 250; /* #60a5fa */ --color-brand-500: 59 130 246; /* #3b82f6 - Primary */ --color-brand-600: 37 99 235; /* #2563eb */ --color-brand-700: 29 78 216; /* #1d4ed8 */ --color-brand-800: 30 64 175; /* #1e40af */ --color-brand-900: 30 58 138; /* #1e3a8a */ --color-brand-950: 23 37 84; /* #172554 */ /* ======================================== * SEMANTIC COLORS - Light Mode * ======================================== */ /* Surfaces */ --color-bg-base: 248 250 252; /* slate-50 */ --color-bg-subtle: 241 245 249; /* slate-100 */ --color-bg-muted: 226 232 240; /* slate-200 */ --color-bg-elevated: 255 255 255; /* white */ --color-bg-overlay: 15 23 42; /* slate-900 */ /* Borders */ --color-border-default: 226 232 240; /* slate-200 */ --color-border-muted: 241 245 249; /* slate-100 */ --color-border-strong: 203 213 225; /* slate-300 */ /* Text */ --color-text-primary: 15 23 42; /* slate-900 */ --color-text-secondary: 71 85 105; /* slate-600 */ --color-text-muted: 148 163 184; /* slate-400 */ --color-text-inverted: 255 255 255; /* white */ /* States */ --color-success: 34 197 94; /* green-500 */ --color-success-muted: 220 252 231; /* green-100 */ --color-warning: 234 179 8; /* yellow-500 */ --color-warning-muted: 254 249 195; /* yellow-100 */ --color-error: 239 68 68; /* red-500 */ --color-error-muted: 254 226 226; /* red-100 */ --color-info: 59 130 246; /* blue-500 */ --color-info-muted: 219 234 254; /* blue-100 */ /* ======================================== * TYPOGRAPHY * ======================================== */ --font-sans: 'Inter', system-ui, -apple-system, sans-serif; --font-mono: 'JetBrains Mono', 'Fira Code', monospace; /* Type Scale (rem) */ --text-xs: 0.75rem; /* 12px */ --text-sm: 0.875rem; /* 14px */ --text-base: 1rem; /* 16px */ --text-lg: 1.125rem; /* 18px */ --text-xl: 1.25rem; /* 20px */ --text-2xl: 1.5rem; /* 24px */ --text-3xl: 1.875rem; /* 30px */ --text-4xl: 2.25rem; /* 36px */ /* Line Heights */ --leading-tight: 1.25; --leading-normal: 1.5; --leading-relaxed: 1.75; /* Font Weights */ --font-normal: 400; --font-medium: 500; --font-semibold: 600; --font-bold: 700; /* ======================================== * SPACING & LAYOUT * ======================================== */ --space-0: 0; --space-1: 0.25rem; /* 4px */ --space-2: 0.5rem; /* 8px */ --space-3: 0.75rem; /* 12px */ --space-4: 1rem; /* 16px */ --space-5: 1.25rem; /* 20px */ --space-6: 1.5rem; /* 24px */ --space-8: 2rem; /* 32px */ --space-10: 2.5rem; /* 40px */ --space-12: 3rem; /* 48px */ --space-16: 4rem; /* 64px */ /* Container */ --container-sm: 640px; --container-md: 768px; --container-lg: 1024px; --container-xl: 1280px; --container-2xl: 1536px; /* Page Gutters */ --page-gutter: var(--space-6); --page-gutter-lg: var(--space-8); /* ======================================== * EFFECTS * ======================================== */ /* Border Radius */ --radius-sm: 0.25rem; /* 4px */ --radius-md: 0.375rem; /* 6px */ --radius-lg: 0.5rem; /* 8px */ --radius-xl: 0.75rem; /* 12px */ --radius-2xl: 1rem; /* 16px */ --radius-full: 9999px; /* Shadows */ --shadow-sm: 0 1px 2px 0 rgb(0 0 0 / 0.05); --shadow-md: 0 4px 6px -1px rgb(0 0 0 / 0.1), 0 2px 4px -2px rgb(0 0 0 / 0.1); --shadow-lg: 0 10px 15px -3px rgb(0 0 0 / 0.1), 0 4px 6px -4px rgb(0 0 0 / 0.1); --shadow-xl: 0 20px 25px -5px rgb(0 0 0 / 0.1), 0 8px 10px -6px rgb(0 0 0 / 0.1); /* Transitions */ --transition-fast: 150ms; --transition-normal: 200ms; --transition-slow: 300ms; --ease-default: cubic-bezier(0.4, 0, 0.2, 1); --ease-in: cubic-bezier(0.4, 0, 1, 1); --ease-out: cubic-bezier(0, 0, 0.2, 1); /* Focus Ring */ --ring-width: 2px; --ring-offset: 2px; --ring-color: var(--color-brand-500); } /* ======================================== * DARK MODE OVERRIDES * ======================================== */ .dark { /* Surfaces */ --color-bg-base: 15 23 42; /* slate-900 */ --color-bg-subtle: 30 41 59; /* slate-800 */ --color-bg-muted: 51 65 85; /* slate-700 */ --color-bg-elevated: 30 41 59; /* slate-800 */ --color-bg-overlay: 2 6 23; /* slate-950 */ /* Borders */ --color-border-default: 51 65 85; /* slate-700 */ --color-border-muted: 30 41 59; /* slate-800 */ --color-border-strong: 71 85 105; /* slate-600 */ /* Text */ --color-text-primary: 248 250 252; /* slate-50 */ --color-text-secondary: 203 213 225; /* slate-300 */ --color-text-muted: 148 163 184; /* slate-400 */ --color-text-inverted: 15 23 42; /* slate-900 */ /* States - Muted versions for dark mode */ --color-success-muted: 20 83 45; /* green-900 */ --color-warning-muted: 113 63 18; /* yellow-900 */ --color-error-muted: 127 29 29; /* red-900 */ --color-info-muted: 30 58 138; /* blue-900 */ } } ``` ### 2.2 Tailwind Configuration (tailwind.config.js) ```javascript /** @type {import('tailwindcss').Config} */ export default { darkMode: 'class', content: [ "./index.html", "./src/**/*.{js,ts,jsx,tsx}", ], theme: { extend: { colors: { // Brand brand: { 50: 'rgb(var(--color-brand-50) / )', 100: 'rgb(var(--color-brand-100) / )', 200: 'rgb(var(--color-brand-200) / )', 300: 'rgb(var(--color-brand-300) / )', 400: 'rgb(var(--color-brand-400) / )', 500: 'rgb(var(--color-brand-500) / )', 600: 'rgb(var(--color-brand-600) / )', 700: 'rgb(var(--color-brand-700) / )', 800: 'rgb(var(--color-brand-800) / )', 900: 'rgb(var(--color-brand-900) / )', 950: 'rgb(var(--color-brand-950) / )', }, // Semantic Surfaces surface: { base: 'rgb(var(--color-bg-base) / )', subtle: 'rgb(var(--color-bg-subtle) / )', muted: 'rgb(var(--color-bg-muted) / )', elevated: 'rgb(var(--color-bg-elevated) / )', overlay: 'rgb(var(--color-bg-overlay) / )', }, // Semantic Borders border: { DEFAULT: 'rgb(var(--color-border-default) / )', muted: 'rgb(var(--color-border-muted) / )', strong: 'rgb(var(--color-border-strong) / )', }, // Semantic Text content: { primary: 'rgb(var(--color-text-primary) / )', secondary: 'rgb(var(--color-text-secondary) / )', muted: 'rgb(var(--color-text-muted) / )', inverted: 'rgb(var(--color-text-inverted) / )', }, // Status Colors success: { DEFAULT: 'rgb(var(--color-success) / )', muted: 'rgb(var(--color-success-muted) / )', }, warning: { DEFAULT: 'rgb(var(--color-warning) / )', muted: 'rgb(var(--color-warning-muted) / )', }, error: { DEFAULT: 'rgb(var(--color-error) / )', muted: 'rgb(var(--color-error-muted) / )', }, info: { DEFAULT: 'rgb(var(--color-info) / )', muted: 'rgb(var(--color-info-muted) / )', }, // Legacy support (deprecate over time) 'dark-bg': '#0f172a', 'dark-sidebar': '#020617', 'dark-card': '#1e293b', 'blue-active': '#1d4ed8', 'blue-hover': '#2563eb', }, fontFamily: { sans: ['var(--font-sans)'], mono: ['var(--font-mono)'], }, fontSize: { xs: ['var(--text-xs)', { lineHeight: 'var(--leading-normal)' }], sm: ['var(--text-sm)', { lineHeight: 'var(--leading-normal)' }], base: ['var(--text-base)', { lineHeight: 'var(--leading-normal)' }], lg: ['var(--text-lg)', { lineHeight: 'var(--leading-normal)' }], xl: ['var(--text-xl)', { lineHeight: 'var(--leading-tight)' }], '2xl': ['var(--text-2xl)', { lineHeight: 'var(--leading-tight)' }], '3xl': ['var(--text-3xl)', { lineHeight: 'var(--leading-tight)' }], '4xl': ['var(--text-4xl)', { lineHeight: 'var(--leading-tight)' }], }, borderRadius: { sm: 'var(--radius-sm)', DEFAULT: 'var(--radius-md)', md: 'var(--radius-md)', lg: 'var(--radius-lg)', xl: 'var(--radius-xl)', '2xl': 'var(--radius-2xl)', }, boxShadow: { sm: 'var(--shadow-sm)', DEFAULT: 'var(--shadow-md)', md: 'var(--shadow-md)', lg: 'var(--shadow-lg)', xl: 'var(--shadow-xl)', }, transitionDuration: { fast: 'var(--transition-fast)', normal: 'var(--transition-normal)', slow: 'var(--transition-slow)', }, spacing: { 'page': 'var(--page-gutter)', 'page-lg': 'var(--page-gutter-lg)', }, }, }, plugins: [], } ``` --- ## 3. Component Library Specifications ### 3.1 Directory Structure ``` frontend/src/components/ui/ ├── index.ts # Barrel exports ├── Button.tsx # ✅ Exists - enhance ├── Card.tsx # ✅ Exists - enhance ├── Input.tsx # ✅ Exists - enhance ├── Switch.tsx # ✅ Exists - enhance ├── Badge.tsx # 🆕 New ├── Alert.tsx # 🆕 New ├── Dialog.tsx # 🆕 New ├── Select.tsx # 🆕 New ├── Tabs.tsx # 🆕 New ├── Tooltip.tsx # 🆕 New ├── DataTable.tsx # 🆕 New ├── Skeleton.tsx # 🆕 New ├── Progress.tsx # 🆕 New ├── Checkbox.tsx # 🆕 New ├── Label.tsx # 🆕 New ├── Textarea.tsx # 🆕 New └── __tests__/ # Component tests ``` ### 3.2 Component Specifications #### Badge Component ```tsx // frontend/src/components/ui/Badge.tsx import { cva, type VariantProps } from 'class-variance-authority' import { cn } from '../../utils/cn' const badgeVariants = cva( 'inline-flex items-center rounded-full px-2.5 py-0.5 text-xs font-medium transition-colors', { variants: { variant: { default: 'bg-surface-muted text-content-primary', primary: 'bg-brand-500/10 text-brand-500', success: 'bg-success-muted text-success', warning: 'bg-warning-muted text-warning', error: 'bg-error-muted text-error', outline: 'border border-border text-content-secondary', }, size: { sm: 'px-2 py-0.5 text-xs', md: 'px-2.5 py-0.5 text-xs', lg: 'px-3 py-1 text-sm', }, }, defaultVariants: { variant: 'default', size: 'md', }, } ) interface BadgeProps extends React.HTMLAttributes, VariantProps { icon?: React.ReactNode } export function Badge({ className, variant, size, icon, children, ...props }: BadgeProps) { return ( {icon && {icon}} {children} ) } ``` #### Alert Component ```tsx // frontend/src/components/ui/Alert.tsx import { cva, type VariantProps } from 'class-variance-authority' import { AlertCircle, CheckCircle, Info, AlertTriangle, X } from 'lucide-react' import { cn } from '../../utils/cn' const alertVariants = cva( 'relative w-full rounded-lg border p-4 [&>svg]:absolute [&>svg]:left-4 [&>svg]:top-4 [&>svg+div]:translate-y-[-3px] [&:has(svg)]:pl-11', { variants: { variant: { default: 'bg-surface-subtle border-border text-content-primary', info: 'bg-info-muted border-info/20 text-info [&>svg]:text-info', success: 'bg-success-muted border-success/20 text-success [&>svg]:text-success', warning: 'bg-warning-muted border-warning/20 text-warning [&>svg]:text-warning', error: 'bg-error-muted border-error/20 text-error [&>svg]:text-error', }, }, defaultVariants: { variant: 'default', }, } ) const iconMap = { default: Info, info: Info, success: CheckCircle, warning: AlertTriangle, error: AlertCircle, } interface AlertProps extends React.HTMLAttributes, VariantProps { title?: string onDismiss?: () => void } export function Alert({ className, variant = 'default', title, children, onDismiss, ...props }: AlertProps) { const Icon = iconMap[variant || 'default'] return (
{title &&
{title}
}
{children}
{onDismiss && ( )}
) } ``` #### Dialog Component ```tsx // frontend/src/components/ui/Dialog.tsx import { Fragment, type ReactNode } from 'react' import { X } from 'lucide-react' import { cn } from '../../utils/cn' interface DialogProps { open: boolean onClose: () => void children: ReactNode className?: string } export function Dialog({ open, onClose, children, className }: DialogProps) { if (!open) return null return (
{/* Backdrop */} ) } export function DialogHeader({ children, className }: { children: ReactNode; className?: string }) { return (
{children}
) } export function DialogTitle({ children, className }: { children: ReactNode; className?: string }) { return (

{children}

) } export function DialogClose({ onClose }: { onClose: () => void }) { return ( ) } export function DialogContent({ children, className }: { children: ReactNode; className?: string }) { return
{children}
} export function DialogFooter({ children, className }: { children: ReactNode; className?: string }) { return (
{children}
) } ``` #### Enhanced Button Component ```tsx // frontend/src/components/ui/Button.tsx (enhanced) import { forwardRef, type ButtonHTMLAttributes, type ReactNode } from 'react' import { cva, type VariantProps } from 'class-variance-authority' import { Loader2 } from 'lucide-react' import { cn } from '../../utils/cn' const buttonVariants = cva( [ 'inline-flex items-center justify-center gap-2 whitespace-nowrap rounded-lg', 'text-sm font-medium transition-all duration-fast', 'focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-brand-500 focus-visible:ring-offset-2', 'disabled:pointer-events-none disabled:opacity-50', 'active:scale-[0.98]', ], { variants: { variant: { primary: 'bg-brand-600 text-white hover:bg-brand-700 shadow-sm', secondary: 'bg-surface-muted text-content-primary hover:bg-surface-subtle border border-border', danger: 'bg-error text-white hover:bg-red-600 shadow-sm', ghost: 'text-content-secondary hover:text-content-primary hover:bg-surface-muted', outline: 'border border-border text-content-primary hover:bg-surface-muted', link: 'text-brand-500 hover:text-brand-600 underline-offset-4 hover:underline', }, size: { sm: 'h-8 px-3 text-xs', md: 'h-10 px-4 text-sm', lg: 'h-12 px-6 text-base', icon: 'h-10 w-10', }, }, defaultVariants: { variant: 'primary', size: 'md', }, } ) interface ButtonProps extends ButtonHTMLAttributes, VariantProps { isLoading?: boolean leftIcon?: ReactNode rightIcon?: ReactNode } export const Button = forwardRef( ({ className, variant, size, isLoading, leftIcon, rightIcon, children, disabled, ...props }, ref) => { return ( ) } ) Button.displayName = 'Button' ``` #### Skeleton Component ```tsx // frontend/src/components/ui/Skeleton.tsx import { cn } from '../../utils/cn' interface SkeletonProps extends React.HTMLAttributes { variant?: 'default' | 'circular' | 'text' } export function Skeleton({ className, variant = 'default', ...props }: SkeletonProps) { return (
) } // Pre-built skeleton patterns export function SkeletonCard() { return (
) } export function SkeletonTable({ rows = 5 }: { rows?: number }) { return (
{[1, 2, 3, 4].map((i) => ( ))}
{Array.from({ length: rows }).map((_, i) => (
{[1, 2, 3, 4].map((j) => ( ))}
))}
) } ``` ### 3.3 Dependencies to Add ```json { "dependencies": { "class-variance-authority": "^0.7.0", "@radix-ui/react-dialog": "^1.0.5", "@radix-ui/react-tooltip": "^1.0.7", "@radix-ui/react-tabs": "^1.0.4", "@radix-ui/react-select": "^2.0.0", "@radix-ui/react-checkbox": "^1.0.4", "@radix-ui/react-progress": "^1.0.3" } } ``` --- ## 4. Layout Improvements ### 4.1 Page Shell Component ```tsx // frontend/src/components/layout/PageShell.tsx import { type ReactNode } from 'react' import { cn } from '../../utils/cn' interface PageShellProps { title: string description?: string actions?: ReactNode children: ReactNode className?: string } export function PageShell({ title, description, actions, children, className }: PageShellProps) { return (

{title}

{description && (

{description}

)}
{actions &&
{actions}
}
{children}
) } ``` ### 4.2 Stats Card Component ```tsx // frontend/src/components/ui/StatsCard.tsx import { type ReactNode } from 'react' import { cn } from '../../utils/cn' import { TrendingUp, TrendingDown, Minus } from 'lucide-react' interface StatsCardProps { title: string value: string | number change?: { value: number trend: 'up' | 'down' | 'neutral' label?: string } icon?: ReactNode href?: string className?: string } export function StatsCard({ title, value, change, icon, href, className }: StatsCardProps) { const Wrapper = href ? 'a' : 'div' const wrapperProps = href ? { href } : {} const TrendIcon = change?.trend === 'up' ? TrendingUp : change?.trend === 'down' ? TrendingDown : Minus const trendColor = change?.trend === 'up' ? 'text-success' : change?.trend === 'down' ? 'text-error' : 'text-content-muted' return (

{title}

{value}

{change && (
{change.value}% {change.label && {change.label}}
)}
{icon && (
{icon}
)}
) } ``` ### 4.3 Empty State Component (Enhanced) ```tsx // frontend/src/components/ui/EmptyState.tsx import { type ReactNode } from 'react' import { cn } from '../../utils/cn' import { Button } from './Button' interface EmptyStateProps { icon?: ReactNode title: string description: string action?: { label: string onClick: () => void variant?: 'primary' | 'secondary' } secondaryAction?: { label: string onClick: () => void } className?: string } export function EmptyState({ icon, title, description, action, secondaryAction, className, }: EmptyStateProps) { return (
{icon && (
{icon}
)}

{title}

{description}

{(action || secondaryAction) && (
{action && ( )} {secondaryAction && ( )}
)}
) } ``` ### 4.4 Data Table Component ```tsx // frontend/src/components/ui/DataTable.tsx import { type ReactNode, useState } from 'react' import { cn } from '../../utils/cn' import { ChevronUp, ChevronDown, ChevronsUpDown } from 'lucide-react' import { Checkbox } from './Checkbox' interface Column { key: string header: string cell: (row: T) => ReactNode sortable?: boolean width?: string } interface DataTableProps { data: T[] columns: Column[] rowKey: (row: T) => string selectable?: boolean selectedKeys?: Set onSelectionChange?: (keys: Set) => void onRowClick?: (row: T) => void emptyState?: ReactNode isLoading?: boolean stickyHeader?: boolean className?: string } export function DataTable({ data, columns, rowKey, selectable, selectedKeys = new Set(), onSelectionChange, onRowClick, emptyState, isLoading, stickyHeader = true, className, }: DataTableProps) { const [sortConfig, setSortConfig] = useState<{ key: string; direction: 'asc' | 'desc' } | null>(null) const handleSort = (key: string) => { setSortConfig((prev) => { if (prev?.key === key) { return prev.direction === 'asc' ? { key, direction: 'desc' } : null } return { key, direction: 'asc' } }) } const handleSelectAll = () => { if (!onSelectionChange) return if (selectedKeys.size === data.length) { onSelectionChange(new Set()) } else { onSelectionChange(new Set(data.map(rowKey))) } } const handleSelectRow = (key: string) => { if (!onSelectionChange) return const newKeys = new Set(selectedKeys) if (newKeys.has(key)) { newKeys.delete(key) } else { newKeys.add(key) } onSelectionChange(newKeys) } const allSelected = data.length > 0 && selectedKeys.size === data.length const someSelected = selectedKeys.size > 0 && selectedKeys.size < data.length return (
{selectable && ( )} {columns.map((col) => ( ))} {data.length === 0 && !isLoading ? ( ) : ( data.map((row) => { const key = rowKey(row) const isSelected = selectedKeys.has(key) return ( onRowClick?.(row)} > {selectable && ( )} {columns.map((col) => ( ))} ) }) )}
col.sortable && handleSort(col.key)} >
{col.header} {col.sortable && ( {sortConfig?.key === col.key ? ( sortConfig.direction === 'asc' ? ( ) : ( ) ) : ( )} )}
{emptyState || (
No data available
)}
e.stopPropagation()}> handleSelectRow(key)} /> {col.cell(row)}
) } ``` --- ## 5. Implementation Phases ### Phase 1: Design Tokens Foundation (Week 1) **Files to Modify:** - [frontend/src/index.css](frontend/src/index.css) - Add CSS variables - [frontend/tailwind.config.js](frontend/tailwind.config.js) - Add semantic color mapping **Files to Create:** - None (modify existing) **Tasks:** 1. Add CSS custom properties to `:root` and `.dark` in index.css 2. Update tailwind.config.js with new color tokens 3. Test light/dark mode switching 4. Verify no visual regressions **Testing:** - Visual regression test for Dashboard, Security, ProxyHosts - Dark/light mode toggle verification - Build succeeds without errors --- ### Phase 2: Core Component Library (Weeks 2-3) **Files to Create:** - [frontend/src/components/ui/Badge.tsx](frontend/src/components/ui/Badge.tsx) - [frontend/src/components/ui/Alert.tsx](frontend/src/components/ui/Alert.tsx) - [frontend/src/components/ui/Dialog.tsx](frontend/src/components/ui/Dialog.tsx) - [frontend/src/components/ui/Select.tsx](frontend/src/components/ui/Select.tsx) - [frontend/src/components/ui/Tabs.tsx](frontend/src/components/ui/Tabs.tsx) - [frontend/src/components/ui/Tooltip.tsx](frontend/src/components/ui/Tooltip.tsx) - [frontend/src/components/ui/Skeleton.tsx](frontend/src/components/ui/Skeleton.tsx) - [frontend/src/components/ui/Progress.tsx](frontend/src/components/ui/Progress.tsx) - [frontend/src/components/ui/Checkbox.tsx](frontend/src/components/ui/Checkbox.tsx) - [frontend/src/components/ui/Label.tsx](frontend/src/components/ui/Label.tsx) - [frontend/src/components/ui/Textarea.tsx](frontend/src/components/ui/Textarea.tsx) - [frontend/src/components/ui/index.ts](frontend/src/components/ui/index.ts) - Barrel exports **Files to Modify:** - [frontend/src/components/ui/Button.tsx](frontend/src/components/ui/Button.tsx) - Enhance with variants - [frontend/src/components/ui/Card.tsx](frontend/src/components/ui/Card.tsx) - Add hover, variants - [frontend/src/components/ui/Input.tsx](frontend/src/components/ui/Input.tsx) - Enhance styling - [frontend/src/components/ui/Switch.tsx](frontend/src/components/ui/Switch.tsx) - Use tokens **Dependencies to Add:** ```bash npm install class-variance-authority @radix-ui/react-dialog @radix-ui/react-tooltip @radix-ui/react-tabs @radix-ui/react-select @radix-ui/react-checkbox @radix-ui/react-progress ``` **Testing:** - Unit tests for each new component - Storybook-style visual verification (manual) - Accessibility audit (keyboard nav, screen reader) --- ### Phase 3: Layout Components (Week 4) **Files to Create:** - [frontend/src/components/layout/PageShell.tsx](frontend/src/components/layout/PageShell.tsx) - [frontend/src/components/ui/StatsCard.tsx](frontend/src/components/ui/StatsCard.tsx) - [frontend/src/components/ui/EmptyState.tsx](frontend/src/components/ui/EmptyState.tsx) (enhance existing) - [frontend/src/components/ui/DataTable.tsx](frontend/src/components/ui/DataTable.tsx) **Files to Modify:** - [frontend/src/components/Layout.tsx](frontend/src/components/Layout.tsx) - Apply token system **Testing:** - Responsive layout tests - Mobile sidebar behavior - Table scrolling with sticky headers --- ### Phase 4: Page-by-Page Polish (Weeks 5-7) #### 4.1 Dashboard (Week 5) **Files to Modify:** - [frontend/src/pages/Dashboard.tsx](frontend/src/pages/Dashboard.tsx) **Changes:** - Replace link cards with `StatsCard` component - Add trend indicators - Improve UptimeWidget styling - Add skeleton loading states - Consistent page padding #### 4.2 ProxyHosts (Week 5) **Files to Modify:** - [frontend/src/pages/ProxyHosts.tsx](frontend/src/pages/ProxyHosts.tsx) - [frontend/src/components/ProxyHostForm.tsx](frontend/src/components/ProxyHostForm.tsx) **Changes:** - Replace inline table with `DataTable` component - Replace inline modals with `Dialog` component - Use `Badge` for SSL/WS/ACL indicators - Use `Alert` for error states - Add `EmptyState` for no hosts #### 4.3 Security Dashboard (Week 6) **Files to Modify:** - [frontend/src/pages/Security.tsx](frontend/src/pages/Security.tsx) **Changes:** - Use enhanced `Card` with hover states - Use `Badge` for status indicators - Improve layer card spacing - Consistent button variants #### 4.4 Settings (Week 6) **Files to Modify:** - [frontend/src/pages/Settings.tsx](frontend/src/pages/Settings.tsx) - [frontend/src/pages/SystemSettings.tsx](frontend/src/pages/SystemSettings.tsx) - [frontend/src/pages/SMTPSettings.tsx](frontend/src/pages/SMTPSettings.tsx) - [frontend/src/pages/Account.tsx](frontend/src/pages/Account.tsx) **Changes:** - Replace tab links with `Tabs` component - Improve form field styling with `Label` - Use `Alert` for validation errors - Consistent page shell #### 4.5 AccessLists (Week 7) **Files to Modify:** - [frontend/src/pages/AccessLists.tsx](frontend/src/pages/AccessLists.tsx) - [frontend/src/components/AccessListForm.tsx](frontend/src/components/AccessListForm.tsx) **Changes:** - Replace inline table with `DataTable` - Replace confirm dialogs with `Dialog` - Use `Alert` for CGNAT warning - Use `Badge` for ACL types #### 4.6 Other Pages (Week 7) **Files to Review/Modify:** - [frontend/src/pages/Certificates.tsx](frontend/src/pages/Certificates.tsx) - [frontend/src/pages/RemoteServers.tsx](frontend/src/pages/RemoteServers.tsx) - [frontend/src/pages/Logs.tsx](frontend/src/pages/Logs.tsx) - [frontend/src/pages/Backups.tsx](frontend/src/pages/Backups.tsx) **Changes:** - Apply consistent `PageShell` wrapper - Use new component library throughout - Add loading skeletons - Improve empty states --- ## 6. Page-by-Page Improvement Checklist ### Dashboard - [ ] Replace link cards with `StatsCard` - [ ] Add trend indicators (up/down arrows) - [ ] Skeleton loading states - [ ] Consistent spacing (page gutter) - [ ] Improve CertificateStatusCard styling ### ProxyHosts - [ ] `DataTable` with sticky header - [ ] `Dialog` for add/edit forms - [ ] `Badge` for SSL/WS/ACL status - [ ] `EmptyState` when no hosts - [ ] Bulk action bar styling - [ ] Loading skeleton ### Security - [ ] Improved layer cards with consistent padding - [ ] `Badge` for status indicators - [ ] Better disabled state styling - [ ] `Alert` for Cerberus disabled message - [ ] Consistent button variants ### Settings - [ ] `Tabs` component for navigation - [ ] Form field consistency - [ ] `Alert` for validation - [ ] Success toast styling ### AccessLists - [ ] `DataTable` with selection - [ ] `Dialog` for confirmations - [ ] `Alert` for CGNAT warning - [ ] `Badge` for ACL types - [ ] `EmptyState` when none exist ### Certificates - [ ] `DataTable` for certificate list - [ ] `Badge` for status (valid/expiring/expired) - [ ] `Dialog` for upload form - [ ] Improved certificate details view ### Logs - [ ] Improved filter styling - [ ] `Badge` for log levels - [ ] Better table density - [ ] Skeleton during load ### Backups - [ ] `DataTable` for backup list - [ ] `Dialog` for restore confirmation - [ ] `Badge` for backup type - [ ] `EmptyState` when none exist --- ## 7. Testing Requirements ### Unit Tests Each new component needs: - Render test (renders without crashing) - Variant tests (all variants render correctly) - Interaction tests (onClick, onChange work) - Accessibility tests (aria labels, keyboard nav) ### Integration Tests - Dark/light mode toggle persists - Page navigation maintains theme - Forms submit correctly with new components - Modals open/close properly ### Visual Regression - Screenshot comparison for: - Dashboard (light + dark) - ProxyHosts table (empty + populated) - Security dashboard (enabled + disabled) - Settings tabs ### Accessibility - WCAG 2.1 AA compliance - Keyboard navigation throughout - Focus visible on all interactive elements - Screen reader compatibility --- ## 8. Migration Strategy ### Backward Compatibility 1. Keep legacy color tokens (`dark-bg`, `dark-card`, etc.) during transition 2. Gradually replace hardcoded colors with semantic tokens 3. Use `cn()` utility for all className merging 4. Create new components alongside existing, migrate pages incrementally ### Rollout Order 1. **Token system** - No visual change, foundation only 2. **New components** - Available but not used 3. **Dashboard** - High visibility, validates approach 4. **ProxyHosts** - Most complex, proves scalability 5. **Remaining pages** - Systematic cleanup ### Deprecation Path After all pages migrated: 1. Remove legacy color tokens from tailwind.config.js 2. Remove inline modal patterns 3. Remove ad-hoc button styling 4. Clean up unused CSS --- ## 9. Success Metrics | Metric | Current | Target | |--------|---------|--------| | Unique color values in CSS | 50+ hardcoded | <20 via tokens | | Component reuse | ~20% | >80% | | Inline styles | Prevalent | Eliminated | | Accessibility score (Lighthouse) | Unknown | 90+ | | Dark/light mode support | Partial | Complete | | Loading states coverage | ~30% | 100% | | Empty states coverage | ~50% | 100% | --- ## 10. Open Questions / Decisions Needed 1. **Font loading strategy**: Should we self-host Inter/JetBrains Mono or use CDN? 2. **Animation library**: Use Framer Motion for complex animations or keep CSS-only? 3. **Form library integration**: Deeper react-hook-form integration with new Input components? 4. **Icon library**: Stick with lucide-react or consider alternatives? 5. **Radix UI scope**: All primitives or selective use for accessibility-critical components? --- ## Appendix A: File Change Summary ### New Files (23) ``` frontend/src/components/ui/Badge.tsx frontend/src/components/ui/Alert.tsx frontend/src/components/ui/Dialog.tsx frontend/src/components/ui/Select.tsx frontend/src/components/ui/Tabs.tsx frontend/src/components/ui/Tooltip.tsx frontend/src/components/ui/Skeleton.tsx frontend/src/components/ui/Progress.tsx frontend/src/components/ui/Checkbox.tsx frontend/src/components/ui/Label.tsx frontend/src/components/ui/Textarea.tsx frontend/src/components/ui/StatsCard.tsx frontend/src/components/ui/EmptyState.tsx frontend/src/components/ui/DataTable.tsx frontend/src/components/ui/index.ts frontend/src/components/layout/PageShell.tsx frontend/src/components/ui/__tests__/Badge.test.tsx frontend/src/components/ui/__tests__/Alert.test.tsx frontend/src/components/ui/__tests__/Dialog.test.tsx frontend/src/components/ui/__tests__/Skeleton.test.tsx frontend/src/components/ui/__tests__/DataTable.test.tsx frontend/src/components/ui/__tests__/EmptyState.test.tsx frontend/src/components/ui/__tests__/StatsCard.test.tsx ``` ### Modified Files (20+) ``` frontend/src/index.css frontend/tailwind.config.js frontend/package.json frontend/src/components/ui/Button.tsx frontend/src/components/ui/Card.tsx frontend/src/components/ui/Input.tsx frontend/src/components/ui/Switch.tsx frontend/src/components/Layout.tsx frontend/src/pages/Dashboard.tsx frontend/src/pages/ProxyHosts.tsx frontend/src/pages/Security.tsx frontend/src/pages/Settings.tsx frontend/src/pages/AccessLists.tsx frontend/src/pages/Certificates.tsx frontend/src/pages/RemoteServers.tsx frontend/src/pages/Logs.tsx frontend/src/pages/Backups.tsx frontend/src/pages/SystemSettings.tsx frontend/src/pages/SMTPSettings.tsx frontend/src/pages/Account.tsx ``` --- *Plan created: December 16, 2025* *Estimated completion: 7 weeks* *Issue reference: GitHub #409*