ADR-033: Shared UI Components β Theme Abstraction & Reusable Patterns
Metadataβ
| Field | Value |
|---|---|
| Status | Accepted |
| Date | 2026-02-06 |
| Linear | CAB-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β
| Principle | Implementation |
|---|---|
| Consistency | Same components in both apps |
| Accessibility | ARIA labels, keyboard navigation |
| Responsive | Mobile-first, breakpoints |
| Dark Mode | CSS 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β
| Challenge | Mitigation |
|---|---|
| Breaking changes | PR requires testing both apps |
| Build complexity | Vite alias configuration |
| Versioning | Monorepo ensures sync |
Referencesβ
Standard Marchemalo: A 40-year veteran architect understands in 30 seconds