Files
Charon/docs/plans/current_spec.md
GitHub Actions 8f2f18edf7 feat: implement modern UI/UX design system (#409)
- 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
2025-12-16 21:21:39 +00:00

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:

  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:

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

  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)

@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:

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:

Files to Modify:

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:

Files to Modify:

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 StatsCard component
  • 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 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:

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:

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:

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:

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