Skip to main content

ADR-033: Shared UI Components β€” Theme Abstraction & Reusable Patterns

Metadata​

FieldValue
StatusAccepted
Date2026-02-06
LinearCAB-1100

Context​

STOA has two React frontends:

  • Console (control-plane-ui) β€” For API providers and platform admins
  • Portal β€” For API consumers and developers

Both applications need:

  • Consistent look and feel
  • Dark/light theme support
  • Common UI patterns (toasts, dialogs, empty states)

The Problem​

"We're duplicating UI components across console and portal. Changes require updating both."

Decision​

Create a shared component library in shared/ that both frontends import.

Structure​

shared/
β”œβ”€β”€ components/
β”‚ β”œβ”€β”€ Breadcrumb/
β”‚ β”‚ └── Breadcrumb.tsx
β”‚ β”œβ”€β”€ Celebration/
β”‚ β”‚ └── Celebration.tsx
β”‚ β”œβ”€β”€ Collapsible/
β”‚ β”‚ └── Collapsible.tsx
β”‚ β”œβ”€β”€ CommandPalette/
β”‚ β”‚ β”œβ”€β”€ CommandPalette.tsx
β”‚ β”‚ └── CommandPalette.css
β”‚ β”œβ”€β”€ ConfirmDialog/
β”‚ β”‚ └── ConfirmDialog.tsx
β”‚ β”œβ”€β”€ EmptyState/
β”‚ β”‚ └── EmptyState.tsx
β”‚ β”œβ”€β”€ FormWizard/
β”‚ β”‚ └── FormWizard.tsx
β”‚ β”œβ”€β”€ Skeleton/
β”‚ β”‚ └── Skeleton.tsx
β”‚ β”œβ”€β”€ ThemeToggle/
β”‚ β”‚ └── ThemeToggle.tsx
β”‚ └── Toast/
β”‚ β”œβ”€β”€ Toast.tsx
β”‚ └── Toast.css
β”‚
└── contexts/
└── ThemeContext.tsx

Components​

ThemeContext​

Centralized theme management:

interface ThemeContextValue {
theme: 'light' | 'dark' | 'system';
setTheme: (theme: 'light' | 'dark' | 'system') => void;
resolvedTheme: 'light' | 'dark';
}

export const ThemeProvider: React.FC<{ children: React.ReactNode }> = ({ children }) => {
const [theme, setTheme] = useState<'light' | 'dark' | 'system'>('system');
const resolvedTheme = useResolvedTheme(theme);

useEffect(() => {
document.documentElement.classList.toggle('dark', resolvedTheme === 'dark');
}, [resolvedTheme]);

return (
<ThemeContext.Provider value={{ theme, setTheme, resolvedTheme }}>
{children}
</ThemeContext.Provider>
);
};

CommandPalette​

Global search and navigation:

interface CommandPaletteProps {
commands: Command[];
onSelect: (command: Command) => void;
placeholder?: string;
}

// Usage
<CommandPalette
commands={[
{ id: 'apis', label: 'Go to APIs', action: () => navigate('/apis') },
{ id: 'tenants', label: 'Go to Tenants', action: () => navigate('/tenants') },
]}
onSelect={(cmd) => cmd.action()}
/>

Toast​

Notification system:

interface ToastProps {
type: 'success' | 'error' | 'warning' | 'info';
message: string;
duration?: number;
onClose: () => void;
}

// Usage via hook
const { toast } = useToast();
toast.success('API created successfully');
toast.error('Failed to delete tenant');

EmptyState​

Consistent empty state displays:

<EmptyState
icon={<FolderOpen />}
title="No APIs found"
description="Create your first API to get started"
action={<Button onClick={createApi}>Create API</Button>}
/>

Import Pattern​

Frontends import from relative path:

// control-plane-ui/src/App.tsx
import { ThemeProvider } from '../../shared/contexts/ThemeContext';
import { Toast } from '../../shared/components/Toast';
import { CommandPalette } from '../../shared/components/CommandPalette';

Design System Principles​

PrincipleImplementation
ConsistencySame components in both apps
AccessibilityARIA labels, keyboard navigation
ResponsiveMobile-first, breakpoints
Dark ModeCSS variables, system preference

Consequences​

Positive​

  • DRY β€” No duplicate component code
  • Consistency β€” Identical UX across apps
  • Maintainability β€” Single source of truth
  • Theme Sync β€” Dark mode works everywhere

Negative​

  • Coupling β€” Changes affect both apps
  • Build Complexity β€” Shared code in monorepo
  • Versioning β€” No independent releases

Mitigations​

ChallengeMitigation
Breaking changesPR requires testing both apps
Build complexityVite alias configuration
VersioningMonorepo ensures sync

References​


Standard Marchemalo: A 40-year veteran architect understands in 30 seconds