- Add comprehensive design token system (colors, typography, spacing) - Create 12 new UI components with Radix UI primitives - Add layout components (PageShell, StatsCard, EmptyState, DataTable) - Polish all pages with new component library - Improve accessibility with WCAG 2.1 compliance - Add dark mode support with semantic color tokens - Update 947 tests to match new UI patterns Closes #409
45 KiB
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:
- Design Token System - Consistent colors, spacing, typography
- Component Library - Reusable, accessible UI primitives
- Layout Improvements - Better dashboards, tables, empty states
- Page Polish - Systematic improvement of all pages
1. Current State Analysis
1.1 Tailwind Configuration (tailwind.config.js)
Current:
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:
: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
- Modal Patterns: Some use
fixed inset-0, some use custom positioning - Button Styling: Mix of
bg-blue-activeandbg-blue-600 - Card Borders: Some use
border-gray-800, othersborder-gray-700 - Text Colors: Inconsistent use of gray scale (gray-400/500 for secondary)
- Spacing: No consistent page gutters or section spacing
- Focus States:
focus:ring-2used but not consistently - Loading States: Custom Charon/Cerberus loaders exist but not used everywhere
2. Design Token System
2.1 CSS Variables (index.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)
/** @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) / <alpha-value>)',
100: 'rgb(var(--color-brand-100) / <alpha-value>)',
200: 'rgb(var(--color-brand-200) / <alpha-value>)',
300: 'rgb(var(--color-brand-300) / <alpha-value>)',
400: 'rgb(var(--color-brand-400) / <alpha-value>)',
500: 'rgb(var(--color-brand-500) / <alpha-value>)',
600: 'rgb(var(--color-brand-600) / <alpha-value>)',
700: 'rgb(var(--color-brand-700) / <alpha-value>)',
800: 'rgb(var(--color-brand-800) / <alpha-value>)',
900: 'rgb(var(--color-brand-900) / <alpha-value>)',
950: 'rgb(var(--color-brand-950) / <alpha-value>)',
},
// Semantic Surfaces
surface: {
base: 'rgb(var(--color-bg-base) / <alpha-value>)',
subtle: 'rgb(var(--color-bg-subtle) / <alpha-value>)',
muted: 'rgb(var(--color-bg-muted) / <alpha-value>)',
elevated: 'rgb(var(--color-bg-elevated) / <alpha-value>)',
overlay: 'rgb(var(--color-bg-overlay) / <alpha-value>)',
},
// Semantic Borders
border: {
DEFAULT: 'rgb(var(--color-border-default) / <alpha-value>)',
muted: 'rgb(var(--color-border-muted) / <alpha-value>)',
strong: 'rgb(var(--color-border-strong) / <alpha-value>)',
},
// Semantic Text
content: {
primary: 'rgb(var(--color-text-primary) / <alpha-value>)',
secondary: 'rgb(var(--color-text-secondary) / <alpha-value>)',
muted: 'rgb(var(--color-text-muted) / <alpha-value>)',
inverted: 'rgb(var(--color-text-inverted) / <alpha-value>)',
},
// Status Colors
success: {
DEFAULT: 'rgb(var(--color-success) / <alpha-value>)',
muted: 'rgb(var(--color-success-muted) / <alpha-value>)',
},
warning: {
DEFAULT: 'rgb(var(--color-warning) / <alpha-value>)',
muted: 'rgb(var(--color-warning-muted) / <alpha-value>)',
},
error: {
DEFAULT: 'rgb(var(--color-error) / <alpha-value>)',
muted: 'rgb(var(--color-error-muted) / <alpha-value>)',
},
info: {
DEFAULT: 'rgb(var(--color-info) / <alpha-value>)',
muted: 'rgb(var(--color-info-muted) / <alpha-value>)',
},
// 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
// 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<HTMLSpanElement>,
VariantProps<typeof badgeVariants> {
icon?: React.ReactNode
}
export function Badge({ className, variant, size, icon, children, ...props }: BadgeProps) {
return (
<span className={cn(badgeVariants({ variant, size }), className)} {...props}>
{icon && <span className="mr-1">{icon}</span>}
{children}
</span>
)
}
Alert Component
// 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<HTMLDivElement>,
VariantProps<typeof alertVariants> {
title?: string
onDismiss?: () => void
}
export function Alert({
className,
variant = 'default',
title,
children,
onDismiss,
...props
}: AlertProps) {
const Icon = iconMap[variant || 'default']
return (
<div role="alert" className={cn(alertVariants({ variant }), className)} {...props}>
<Icon className="h-4 w-4" />
<div className="flex-1">
{title && <h5 className="mb-1 font-medium leading-none tracking-tight">{title}</h5>}
<div className="text-sm [&_p]:leading-relaxed">{children}</div>
</div>
{onDismiss && (
<button
onClick={onDismiss}
className="absolute right-2 top-2 rounded-md p-1 opacity-70 hover:opacity-100 transition-opacity"
>
<X className="h-4 w-4" />
</button>
)}
</div>
)
}
Dialog Component
// 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 (
<div className="fixed inset-0 z-50 overflow-y-auto">
{/* Backdrop */}
<div
className="fixed inset-0 bg-surface-overlay/80 backdrop-blur-sm transition-opacity"
onClick={onClose}
aria-hidden="true"
/>
{/* Dialog container */}
<div className="flex min-h-full items-center justify-center p-4">
<div
className={cn(
'relative w-full max-w-lg transform overflow-hidden rounded-xl',
'bg-surface-elevated border border-border shadow-xl',
'transition-all duration-normal',
'animate-in fade-in-0 zoom-in-95',
className
)}
role="dialog"
aria-modal="true"
>
{children}
</div>
</div>
</div>
)
}
export function DialogHeader({ children, className }: { children: ReactNode; className?: string }) {
return (
<div className={cn('flex items-center justify-between p-6 border-b border-border', className)}>
{children}
</div>
)
}
export function DialogTitle({ children, className }: { children: ReactNode; className?: string }) {
return (
<h2 className={cn('text-lg font-semibold text-content-primary', className)}>
{children}
</h2>
)
}
export function DialogClose({ onClose }: { onClose: () => void }) {
return (
<button
onClick={onClose}
className="rounded-md p-1 text-content-muted hover:text-content-primary hover:bg-surface-muted transition-colors"
>
<X className="h-5 w-5" />
</button>
)
}
export function DialogContent({ children, className }: { children: ReactNode; className?: string }) {
return <div className={cn('p-6', className)}>{children}</div>
}
export function DialogFooter({ children, className }: { children: ReactNode; className?: string }) {
return (
<div className={cn('flex items-center justify-end gap-3 p-6 border-t border-border bg-surface-subtle', className)}>
{children}
</div>
)
}
Enhanced Button Component
// 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<HTMLButtonElement>,
VariantProps<typeof buttonVariants> {
isLoading?: boolean
leftIcon?: ReactNode
rightIcon?: ReactNode
}
export const Button = forwardRef<HTMLButtonElement, ButtonProps>(
({ className, variant, size, isLoading, leftIcon, rightIcon, children, disabled, ...props }, ref) => {
return (
<button
ref={ref}
className={cn(buttonVariants({ variant, size }), className)}
disabled={disabled || isLoading}
{...props}
>
{isLoading ? (
<Loader2 className="h-4 w-4 animate-spin" />
) : leftIcon ? (
<span className="shrink-0">{leftIcon}</span>
) : null}
{children}
{rightIcon && !isLoading && <span className="shrink-0">{rightIcon}</span>}
</button>
)
}
)
Button.displayName = 'Button'
Skeleton Component
// frontend/src/components/ui/Skeleton.tsx
import { cn } from '../../utils/cn'
interface SkeletonProps extends React.HTMLAttributes<HTMLDivElement> {
variant?: 'default' | 'circular' | 'text'
}
export function Skeleton({ className, variant = 'default', ...props }: SkeletonProps) {
return (
<div
className={cn(
'animate-pulse bg-surface-muted',
{
'rounded-md': variant === 'default',
'rounded-full': variant === 'circular',
'rounded h-4 w-full': variant === 'text',
},
className
)}
{...props}
/>
)
}
// Pre-built skeleton patterns
export function SkeletonCard() {
return (
<div className="rounded-lg border border-border p-6 space-y-4">
<Skeleton className="h-4 w-1/3" />
<Skeleton className="h-8 w-1/2" />
<div className="space-y-2">
<Skeleton variant="text" />
<Skeleton variant="text" className="w-5/6" />
</div>
</div>
)
}
export function SkeletonTable({ rows = 5 }: { rows?: number }) {
return (
<div className="rounded-lg border border-border overflow-hidden">
<div className="border-b border-border p-4 bg-surface-subtle">
<div className="flex gap-4">
{[1, 2, 3, 4].map((i) => (
<Skeleton key={i} className="h-4 flex-1" />
))}
</div>
</div>
<div className="divide-y divide-border">
{Array.from({ length: rows }).map((_, i) => (
<div key={i} className="p-4 flex gap-4">
{[1, 2, 3, 4].map((j) => (
<Skeleton key={j} className="h-4 flex-1" />
))}
</div>
))}
</div>
</div>
)
}
3.3 Dependencies to Add
{
"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
// 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 (
<div className={cn('space-y-6', className)}>
<header className="flex flex-col gap-4 sm:flex-row sm:items-center sm:justify-between">
<div>
<h1 className="text-2xl font-bold text-content-primary">{title}</h1>
{description && (
<p className="mt-1 text-sm text-content-secondary">{description}</p>
)}
</div>
{actions && <div className="flex items-center gap-3">{actions}</div>}
</header>
{children}
</div>
)
}
4.2 Stats Card Component
// 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 (
<Wrapper
{...wrapperProps}
className={cn(
'block rounded-xl border border-border bg-surface-elevated p-6',
'transition-all duration-fast',
href && 'hover:shadow-md hover:border-brand-500/50 cursor-pointer',
className
)}
>
<div className="flex items-start justify-between">
<div>
<p className="text-sm font-medium text-content-secondary">{title}</p>
<p className="mt-2 text-3xl font-bold text-content-primary">{value}</p>
{change && (
<div className={cn('mt-2 flex items-center gap-1 text-sm', trendColor)}>
<TrendIcon className="h-4 w-4" />
<span>{change.value}%</span>
{change.label && <span className="text-content-muted">{change.label}</span>}
</div>
)}
</div>
{icon && (
<div className="rounded-lg bg-brand-500/10 p-3 text-brand-500">
{icon}
</div>
)}
</div>
</Wrapper>
)
}
4.3 Empty State Component (Enhanced)
// 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 (
<div
className={cn(
'flex flex-col items-center justify-center py-16 px-6 text-center',
'rounded-xl border border-dashed border-border bg-surface-subtle/50',
className
)}
>
{icon && (
<div className="mb-4 rounded-full bg-surface-muted p-4 text-content-muted">
{icon}
</div>
)}
<h3 className="text-lg font-semibold text-content-primary">{title}</h3>
<p className="mt-2 max-w-sm text-sm text-content-secondary">{description}</p>
{(action || secondaryAction) && (
<div className="mt-6 flex items-center gap-3">
{action && (
<Button variant={action.variant || 'primary'} onClick={action.onClick}>
{action.label}
</Button>
)}
{secondaryAction && (
<Button variant="ghost" onClick={secondaryAction.onClick}>
{secondaryAction.label}
</Button>
)}
</div>
)}
</div>
)
}
4.4 Data Table Component
// 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<T> {
key: string
header: string
cell: (row: T) => ReactNode
sortable?: boolean
width?: string
}
interface DataTableProps<T> {
data: T[]
columns: Column<T>[]
rowKey: (row: T) => string
selectable?: boolean
selectedKeys?: Set<string>
onSelectionChange?: (keys: Set<string>) => void
onRowClick?: (row: T) => void
emptyState?: ReactNode
isLoading?: boolean
stickyHeader?: boolean
className?: string
}
export function DataTable<T>({
data,
columns,
rowKey,
selectable,
selectedKeys = new Set(),
onSelectionChange,
onRowClick,
emptyState,
isLoading,
stickyHeader = true,
className,
}: DataTableProps<T>) {
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 (
<div className={cn('rounded-xl border border-border overflow-hidden', className)}>
<div className="overflow-x-auto">
<table className="w-full">
<thead className={cn(
'bg-surface-subtle border-b border-border',
stickyHeader && 'sticky top-0 z-10'
)}>
<tr>
{selectable && (
<th className="w-12 px-4 py-3">
<Checkbox
checked={allSelected}
indeterminate={someSelected}
onChange={handleSelectAll}
/>
</th>
)}
{columns.map((col) => (
<th
key={col.key}
className={cn(
'px-6 py-3 text-left text-xs font-semibold uppercase tracking-wider text-content-secondary',
col.sortable && 'cursor-pointer select-none hover:text-content-primary'
)}
style={{ width: col.width }}
onClick={() => col.sortable && handleSort(col.key)}
>
<div className="flex items-center gap-1">
{col.header}
{col.sortable && (
<span className="text-content-muted">
{sortConfig?.key === col.key ? (
sortConfig.direction === 'asc' ? (
<ChevronUp className="h-4 w-4" />
) : (
<ChevronDown className="h-4 w-4" />
)
) : (
<ChevronsUpDown className="h-4 w-4 opacity-50" />
)}
</span>
)}
</div>
</th>
))}
</tr>
</thead>
<tbody className="divide-y divide-border bg-surface-elevated">
{data.length === 0 && !isLoading ? (
<tr>
<td colSpan={columns.length + (selectable ? 1 : 0)} className="px-6 py-12">
{emptyState || (
<div className="text-center text-content-muted">No data available</div>
)}
</td>
</tr>
) : (
data.map((row) => {
const key = rowKey(row)
const isSelected = selectedKeys.has(key)
return (
<tr
key={key}
className={cn(
'transition-colors',
isSelected && 'bg-brand-500/5',
onRowClick && 'cursor-pointer hover:bg-surface-muted',
!onRowClick && 'hover:bg-surface-subtle'
)}
onClick={() => onRowClick?.(row)}
>
{selectable && (
<td className="w-12 px-4 py-4" onClick={(e) => e.stopPropagation()}>
<Checkbox
checked={isSelected}
onChange={() => handleSelectRow(key)}
/>
</td>
)}
{columns.map((col) => (
<td key={col.key} className="px-6 py-4 text-sm text-content-primary">
{col.cell(row)}
</td>
))}
</tr>
)
})
)}
</tbody>
</table>
</div>
</div>
)
}
5. Implementation Phases
Phase 1: Design Tokens Foundation (Week 1)
Files to Modify:
- frontend/src/index.css - Add CSS variables
- frontend/tailwind.config.js - Add semantic color mapping
Files to Create:
- None (modify existing)
Tasks:
- Add CSS custom properties to
:rootand.darkin index.css - Update tailwind.config.js with new color tokens
- Test light/dark mode switching
- 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/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/index.ts - Barrel exports
Files to Modify:
- frontend/src/components/ui/Button.tsx - Enhance with variants
- frontend/src/components/ui/Card.tsx - Add hover, variants
- frontend/src/components/ui/Input.tsx - Enhance styling
- frontend/src/components/ui/Switch.tsx - Use tokens
Dependencies to Add:
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/ui/StatsCard.tsx
- frontend/src/components/ui/EmptyState.tsx (enhance existing)
- frontend/src/components/ui/DataTable.tsx
Files to Modify:
- 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:
Changes:
- Replace link cards with
StatsCardcomponent - Add trend indicators
- Improve UptimeWidget styling
- Add skeleton loading states
- Consistent page padding
4.2 ProxyHosts (Week 5)
Files to Modify:
Changes:
- Replace inline table with
DataTablecomponent - Replace inline modals with
Dialogcomponent - Use
Badgefor SSL/WS/ACL indicators - Use
Alertfor error states - Add
EmptyStatefor no hosts
4.3 Security Dashboard (Week 6)
Files to Modify:
Changes:
- Use enhanced
Cardwith hover states - Use
Badgefor 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/SystemSettings.tsx
- frontend/src/pages/SMTPSettings.tsx
- frontend/src/pages/Account.tsx
Changes:
- Replace tab links with
Tabscomponent - Improve form field styling with
Label - Use
Alertfor validation errors - Consistent page shell
4.5 AccessLists (Week 7)
Files to Modify:
Changes:
- Replace inline table with
DataTable - Replace confirm dialogs with
Dialog - Use
Alertfor CGNAT warning - Use
Badgefor ACL types
4.6 Other Pages (Week 7)
Files to Review/Modify:
- frontend/src/pages/Certificates.tsx
- frontend/src/pages/RemoteServers.tsx
- frontend/src/pages/Logs.tsx
- frontend/src/pages/Backups.tsx
Changes:
- Apply consistent
PageShellwrapper - 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
DataTablewith sticky headerDialogfor add/edit formsBadgefor SSL/WS/ACL statusEmptyStatewhen no hosts- Bulk action bar styling
- Loading skeleton
Security
- Improved layer cards with consistent padding
Badgefor status indicators- Better disabled state styling
Alertfor Cerberus disabled message- Consistent button variants
Settings
Tabscomponent for navigation- Form field consistency
Alertfor validation- Success toast styling
AccessLists
DataTablewith selectionDialogfor confirmationsAlertfor CGNAT warningBadgefor ACL typesEmptyStatewhen none exist
Certificates
DataTablefor certificate listBadgefor status (valid/expiring/expired)Dialogfor upload form- Improved certificate details view
Logs
- Improved filter styling
Badgefor log levels- Better table density
- Skeleton during load
Backups
DataTablefor backup listDialogfor restore confirmationBadgefor backup typeEmptyStatewhen 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
- Keep legacy color tokens (
dark-bg,dark-card, etc.) during transition - Gradually replace hardcoded colors with semantic tokens
- Use
cn()utility for all className merging - Create new components alongside existing, migrate pages incrementally
Rollout Order
- Token system - No visual change, foundation only
- New components - Available but not used
- Dashboard - High visibility, validates approach
- ProxyHosts - Most complex, proves scalability
- Remaining pages - Systematic cleanup
Deprecation Path
After all pages migrated:
- Remove legacy color tokens from tailwind.config.js
- Remove inline modal patterns
- Remove ad-hoc button styling
- 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
- Font loading strategy: Should we self-host Inter/JetBrains Mono or use CDN?
- Animation library: Use Framer Motion for complex animations or keep CSS-only?
- Form library integration: Deeper react-hook-form integration with new Input components?
- Icon library: Stick with lucide-react or consider alternatives?
- 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