Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
27 KiB
Architecture Patterns
Domain: UI polish overhaul -- Material Design 3 theming, dark mode, and component refactoring for existing Tailwind v4 + React wizard app Researched: 2026-03-31
Recommended Architecture
Overview
The architecture centers on a CSS custom properties layer that bridges Material Design 3 color tokens with Tailwind v4's @theme directive. A thin React ThemeProvider context manages dark/light state and persists preference to localStorage. Components are refactored bottom-up from primitives (inputs, buttons, cards) to composed wizard steps, using semantic token names in Tailwind classes rather than hardcoded colors.
+---------------------+
| ThemeProvider | React Context
| (dark/light state) | localStorage + prefers-color-scheme
+---------------------+
|
sets .dark on <html>
|
+---------------------+
| index.css | CSS custom properties layer
| @theme { tokens } | MD3 color roles as --color-*
| .dark { overrides }| Dark palette overrides
+---------------------+
|
+---------------------+
| Tailwind v4 | Consumes tokens via @theme
| bg-surface | Semantic utility classes
| text-on-surface |
+---------------------+
|
+---------------+---------------+
| | |
+----------+ +-----------+ +------------+
| ui/ | | wizard/ | | layout/ |
| Button | | Steps | | AppShell |
| Input | | Indicator | | ThemeToggle|
| Card | | AuthToggles| +------------+
+----------+ +-----------+
Component Boundaries
| Component | Responsibility | Communicates With | Status |
|---|---|---|---|
ThemeProvider |
Manages dark/light state, syncs to DOM and localStorage | <html> element class, useTheme consumers |
NEW |
useTheme hook |
Exposes { theme, toggleTheme, setTheme } |
ThemeProvider context | NEW |
ThemeToggle |
UI control for dark/light switch | useTheme hook | NEW |
AppShell |
Outer layout wrapper (background, max-width, header) | ThemeProvider, WizardShell | NEW (extracted from App.tsx WizardShell) |
ui/Button |
MD3-styled button with variants (filled, outlined, text) | None (pure presentational) | NEW |
ui/Input |
MD3-styled text input with label and error state | react-hook-form via register | NEW (replaces inline input markup in FieldRenderer) |
ui/Select |
MD3-styled select dropdown | react-hook-form via register | NEW (replaces inline select markup in FieldRenderer) |
ui/Card |
MD3 surface card with elevation | None (pure presentational) | NEW |
ui/FieldRenderer |
Composes Input/Select/PasswordField based on FieldDef | ui/Input, ui/Select, ui/PasswordField | MODIFIED -- delegates to primitives |
ui/PasswordField |
Password input with show/hide toggle | react-hook-form | MODIFIED -- uses ui/Input internally |
ui/BackendCard |
Backend selection card | ui/Card | MODIFIED -- uses ui/Card internally |
wizard/StepIndicator |
Breadcrumb navigation | useWizard | MODIFIED -- inline styles to Tailwind + MD3 tokens |
wizard/*Step |
Step content | useWizard, ui components | MODIFIED -- swap hardcoded colors for semantic tokens |
Data Flow
Theme state flow:
- On app mount,
ThemeProviderreadslocalStorage.getItem('r2b-theme') - If no stored preference, checks
window.matchMedia('(prefers-color-scheme: dark)').matches - Sets/removes
.darkclass ondocument.documentElement - Tailwind's
@custom-variant darkactivatesdark:prefix utilities - CSS custom properties in
.darkscope override light palette values - All components using semantic tokens (
bg-surface,text-on-surface) update automatically
Color token flow:
index.cssdefines MD3 color tokens as CSS custom properties in@theme- Light values are defaults;
.darkclass overrides with dark palette values - Tailwind v4 maps these to utility classes (e.g.,
--color-surfacebecomesbg-surface) - Components reference semantic names, never raw hex values
CSS Custom Properties Strategy for MD3 Color Tokens
Token Definition in index.css
This is the core architectural decision. Tailwind v4 uses @theme to define custom theme values consumed as utility classes. MD3 color roles map directly to CSS custom properties.
@import "tailwindcss";
/* Enable class-based dark mode toggle */
@custom-variant dark (&:where(.dark, .dark *));
/* --- MD3 Color Tokens as Tailwind v4 theme --- */
@theme {
/* Primary */
--color-primary: #1a73e8;
--color-on-primary: #ffffff;
--color-primary-container: #d3e3fd;
--color-on-primary-container: #041e49;
/* Secondary */
--color-secondary: #5f6368;
--color-on-secondary: #ffffff;
--color-secondary-container: #e8eaed;
--color-on-secondary-container: #1f1f1f;
/* Tertiary (accent) */
--color-tertiary: #1a73e8;
--color-on-tertiary: #ffffff;
--color-tertiary-container: #d3e3fd;
--color-on-tertiary-container: #041e49;
/* Error */
--color-error: #dc3545;
--color-on-error: #ffffff;
--color-error-container: #f9dedc;
--color-on-error-container: #410e0b;
/* Surface & Background */
--color-surface: #ffffff;
--color-surface-dim: #f1f3f4;
--color-surface-container: #f8f9fa;
--color-surface-container-low: #f1f3f4;
--color-surface-container-high: #e8eaed;
--color-on-surface: #1f1f1f;
--color-on-surface-variant: #5f6368;
/* Outline */
--color-outline: #dadce0;
--color-outline-variant: #e8eaed;
/* MD3 Elevation (shadow) */
--shadow-elevation-1: 0 1px 2px 0 rgb(0 0 0 / 0.05);
--shadow-elevation-2: 0 1px 3px 0 rgb(0 0 0 / 0.1), 0 1px 2px -1px rgb(0 0 0 / 0.1);
--shadow-elevation-3: 0 4px 6px -1px rgb(0 0 0 / 0.1), 0 2px 4px -2px rgb(0 0 0 / 0.1);
/* MD3 Shape (border-radius) */
--radius-sm: 8px;
--radius-md: 12px;
--radius-lg: 16px;
--radius-xl: 28px;
}
/* --- Dark mode overrides --- */
@layer base {
.dark {
--color-primary: #a8c7fa;
--color-on-primary: #062e6f;
--color-primary-container: #0842a0;
--color-on-primary-container: #d3e3fd;
--color-secondary: #c4c7c5;
--color-on-secondary: #303030;
--color-secondary-container: #444746;
--color-on-secondary-container: #e8eaed;
--color-tertiary: #a8c7fa;
--color-on-tertiary: #062e6f;
--color-tertiary-container: #0842a0;
--color-on-tertiary-container: #d3e3fd;
--color-error: #f2b8b5;
--color-on-error: #601410;
--color-error-container: #8c1d18;
--color-on-error-container: #f9dedc;
--color-surface: #1f1f1f;
--color-surface-dim: #141414;
--color-surface-container: #2d2d2d;
--color-surface-container-low: #262626;
--color-surface-container-high: #3c3c3c;
--color-on-surface: #e8eaed;
--color-on-surface-variant: #c4c7c5;
--color-outline: #5f6368;
--color-outline-variant: #444746;
--shadow-elevation-1: 0 1px 3px 0 rgb(0 0 0 / 0.3);
--shadow-elevation-2: 0 2px 6px 0 rgb(0 0 0 / 0.3);
--shadow-elevation-3: 0 4px 8px 0 rgb(0 0 0 / 0.3);
}
}
Why This Approach (Not a Component Library)
- No dependency on MD3 web components --
@material/webis Angular/Lit-oriented and would fight React + Tailwind. The MD3 spec is a design system, not a library requirement. - Tailwind v4 @theme is the native mechanism -- tokens defined in
@themebecome first-class Tailwind utilities (bg-surface,text-on-primary,shadow-elevation-2). No plugins needed. - Dark mode is a CSS variable swap -- the
.darkclass override block is all that's needed. No JS re-rendering, no theme prop drilling to every component. - Accent color extensibility -- to support user-selectable accent colors later, just override
--color-primaryand related tokens at runtime via JS ondocument.documentElement.style.
MD3 Color Roles Used (Practical Subset)
Full MD3 has 29+ color roles. For this wizard app, we use a practical subset:
| MD3 Role | Tailwind Class | Used For |
|---|---|---|
surface |
bg-surface |
Page background, card backgrounds |
on-surface |
text-on-surface |
Primary text |
on-surface-variant |
text-on-surface-variant |
Secondary text, help text |
surface-dim |
bg-surface-dim |
Page background (current bg-gray-50) |
surface-container |
bg-surface-container |
Card fills, input backgrounds |
surface-container-high |
bg-surface-container-high |
Elevated cards, active states |
primary |
bg-primary, text-primary |
Primary buttons, active indicators |
on-primary |
text-on-primary |
Text on primary buttons |
primary-container |
bg-primary-container |
Selected BackendCard fill |
on-primary-container |
text-on-primary-container |
Text on selected BackendCard |
error |
text-error |
Validation error text |
error-container |
bg-error-container |
Error badge backgrounds |
outline |
border-outline |
Input borders, dividers |
outline-variant |
border-outline-variant |
Subtle borders |
Current-to-Token Mapping
Explicit mapping of every hardcoded color in the existing codebase:
| Current Class | Semantic Token | Where Used |
|---|---|---|
bg-gray-50 |
bg-surface-dim |
App.tsx page background |
text-gray-900 |
text-on-surface |
App.tsx heading, BackendCard name |
text-gray-500 |
text-on-surface-variant |
BackendCard description, help text |
bg-white |
bg-surface |
BackendCard default, card backgrounds |
border-gray-200 |
border-outline-variant |
BackendCard default border |
border-gray-300 |
border-outline |
Input borders, Back button border |
hover:border-blue-400 |
hover:border-primary/60 |
BackendCard hover |
hover:bg-gray-50 |
hover:bg-surface-container |
BackendCard hover, Back button hover |
border-blue-600 |
border-primary |
BackendCard selected border |
bg-blue-50 |
bg-primary-container |
BackendCard selected fill, tooltip bg |
bg-blue-600 |
bg-primary |
Next/primary buttons |
text-white (on blue bg) |
text-on-primary |
Primary button text |
hover:bg-blue-700 |
hover:bg-primary/90 |
Primary button hover |
text-red-500 |
text-error |
Required field asterisk |
text-red-600 |
text-error |
Error message text |
border-red-500 |
border-error |
Error input border |
focus:ring-blue-300 |
focus:ring-primary/40 |
Input focus ring |
focus:ring-red-300 |
focus:ring-error/40 |
Error input focus ring |
text-blue-500 |
text-primary |
Tooltip toggle button |
text-blue-700 |
text-on-primary-container |
Tooltip text |
border-blue-200 |
border-primary-container |
Tooltip border |
text-gray-400 |
text-on-surface-variant |
Password show/hide button |
hover:text-gray-700 |
hover:text-on-surface |
Password show/hide hover |
#999 (inline) |
text-on-surface-variant |
StepIndicator future steps |
fontWeight: bold (inline) |
font-bold |
StepIndicator active step |
fontWeight: normal (inline) |
font-normal |
StepIndicator completed step |
ThemeProvider Architecture
Implementation
// src/store/theme-context.tsx
import { createContext, useContext, useEffect, useState, useCallback } from 'react';
type Theme = 'light' | 'dark';
interface ThemeContextValue {
theme: Theme;
toggleTheme: () => void;
setTheme: (theme: Theme) => void;
}
const ThemeContext = createContext<ThemeContextValue | null>(null);
function getInitialTheme(): Theme {
if (typeof window === 'undefined') return 'light';
const stored = localStorage.getItem('r2b-theme') as Theme | null;
if (stored === 'light' || stored === 'dark') return stored;
return window.matchMedia('(prefers-color-scheme: dark)').matches ? 'dark' : 'light';
}
export function ThemeProvider({ children }: { children: React.ReactNode }) {
const [theme, setThemeState] = useState<Theme>(getInitialTheme);
const setTheme = useCallback((t: Theme) => {
setThemeState(t);
localStorage.setItem('r2b-theme', t);
document.documentElement.classList.toggle('dark', t === 'dark');
}, []);
const toggleTheme = useCallback(() => {
setTheme(theme === 'dark' ? 'light' : 'dark');
}, [theme, setTheme]);
// Sync on mount
useEffect(() => {
document.documentElement.classList.toggle('dark', theme === 'dark');
}, []);
return (
<ThemeContext.Provider value={{ theme, toggleTheme, setTheme }}>
{children}
</ThemeContext.Provider>
);
}
export function useTheme(): ThemeContextValue {
const ctx = useContext(ThemeContext);
if (!ctx) throw new Error('useTheme must be used inside <ThemeProvider>');
return ctx;
}
Provider Nesting in App.tsx
// ThemeProvider wraps WizardProvider -- theme is app-global, wizard state is feature-scoped
export default function App() {
return (
<ThemeProvider>
<WizardProvider>
<AppShell />
</WizardProvider>
</ThemeProvider>
);
}
Rationale: ThemeProvider is outermost because theme affects the entire DOM tree. WizardProvider is inner because it only governs wizard state. They are independent -- no cross-dependencies.
Component Refactoring Approach: Bottom-Up Primitives
Why Bottom-Up (Not Top-Down)
- Primitives are the reuse boundary -- Input, Button, Card are used by multiple wizard steps. Fix once, propagate everywhere.
- Tests target behavior, not styles -- existing 159 tests use Testing Library (query by role, text, label). Changing CSS classes does not break tests. Changing component structure (splitting FieldRenderer) could break tests if DOM hierarchy changes.
- Incremental migration -- each primitive can be built, tested, and swapped in isolation. No big-bang rewrite.
Refactoring Layers
Layer 1: CSS Foundation (index.css)
- MD3 tokens in @theme
- Dark mode @custom-variant + .dark overrides
- Zero component changes needed
- Zero test impact
Layer 2: Primitive Components (ui/)
- NEW: Button, Input, Select, Card
- Tests: New tests for new components only
Layer 3: Composed Components (ui/)
- MODIFIED: FieldRenderer -- delegates to Input/Select/PasswordField
- MODIFIED: BackendCard -- delegates to Card
- MODIFIED: PasswordField -- uses Input primitive internally
- Tests: Existing tests should pass (same DOM semantics, different styling)
Layer 4: Wizard Steps + StepIndicator (wizard/)
- MODIFIED: Replace hardcoded Tailwind classes with semantic tokens
- MODIFIED: StepIndicator -- inline styles to Tailwind classes
- Tests: Existing tests should pass (no behavior change)
Layer 5: Layout Shell + Polish
- NEW: AppShell (extracted from WizardShell in App.tsx)
- NEW: ThemeToggle
- MODIFIED: App.tsx -- adds ThemeProvider, uses AppShell
- Tests: App.test.tsx needs update for new provider wrapping
Migration Pattern Per Component
For each existing component, the migration follows this pattern:
- Replace hardcoded colors with semantic tokens using the Current-to-Token Mapping table above
- Add
dark:variants only where semantic tokens alone are insufficient (should be rare -- the CSS variable swap handles most cases automatically) - Replace inline styles with Tailwind classes (StepIndicator specific)
- Run existing tests after each component -- they should pass unchanged
Patterns to Follow
Pattern 1: Semantic Color Tokens Only
What: Never use raw Tailwind color classes (blue-600, gray-50) in components. Always use semantic MD3 token names.
When: Every component, every color reference.
Why: Semantic tokens automatically adapt to dark mode via CSS variable override. Raw colors would need manual dark: overrides on every single usage.
Example:
// WRONG -- requires dark: override on every element
<button className="bg-blue-600 text-white dark:bg-blue-400 dark:text-gray-900">
// RIGHT -- adapts automatically via CSS variable swap
<button className="bg-primary text-on-primary">
Pattern 2: Component Composition Over Monoliths
What: Extract reusable UI primitives (Button, Input, Card) from inline markup.
When: Any UI element used in 2+ places, or any element with complex styling logic.
Example:
// BEFORE: Inline button styling in every step component
<button className="px-4 py-2 text-sm bg-primary text-on-primary rounded-md hover:bg-primary/90">
Next
</button>
// AFTER: Reusable Button primitive
<Button variant="filled">Next</Button>
<Button variant="outlined">Back</Button>
<Button variant="text">Cancel</Button>
Pattern 3: Test Helper for Theme Context
What: Create a test utility that wraps components in both ThemeProvider and WizardProvider.
When: Any component test that renders a component needing theme context.
Example:
// src/test-utils.tsx
import { render } from '@testing-library/react';
import { ThemeProvider } from './store/theme-context';
import { WizardProvider } from './store/context';
export function renderWithProviders(ui: React.ReactElement) {
return render(
<ThemeProvider>
<WizardProvider>
{ui}
</WizardProvider>
</ThemeProvider>
);
}
Pattern 4: MD3 Elevation via Shadow Tokens
What: Use the shadow-elevation-* tokens for card depth instead of arbitrary shadow utilities.
When: Cards, modals, dropdowns -- any elevated surface.
Example:
// Card at rest
<div className="bg-surface-container rounded-lg shadow-elevation-1">
// Card hovered / elevated
<div className="bg-surface-container rounded-lg shadow-elevation-2 hover:shadow-elevation-3">
Anti-Patterns to Avoid
Anti-Pattern 1: Dual-Track Color System
What: Keeping some components on raw Tailwind colors while migrating others to MD3 tokens.
Why bad: Dark mode will be broken on non-migrated components. Visual inconsistency. Harder to maintain.
Instead: Migrate ALL color references in one sweep per component. The token names are a 1:1 replacement -- no logic changes needed.
Anti-Pattern 2: JS-Driven Theme Prop Drilling
What: Passing isDark or theme as a prop to every component and using ternaries to pick colors.
Why bad: Massive prop threading, re-renders on theme change, duplicated color logic.
Instead: CSS custom properties handle the switch. Components just use bg-surface -- the browser resolves the correct value based on .dark class presence. Only ThemeToggle needs to consume useTheme.
Anti-Pattern 3: Using @material/web Components
What: Importing Google's Material Web Components (<md-button>, <md-text-field>).
Why bad: These are web components designed for vanilla JS / Lit. They fight React's rendering model, do not integrate with react-hook-form's register(), and add significant bundle size. They use Shadow DOM which conflicts with Tailwind's utility approach.
Instead: Implement MD3's visual language (colors, elevation, shape, typography) via CSS tokens + Tailwind utilities on standard React elements.
Anti-Pattern 4: Big-Bang Step Rewrite
What: Rewriting entire wizard step components at once with new UI.
Why bad: Breaks multiple tests simultaneously. Hard to isolate regressions. Merge conflicts if concurrent work.
Instead: Layer-by-layer approach. CSS tokens first (zero breakage), then primitives (additive), then swap in composed components, then layout.
Impact on Existing Test Suite (159 Tests)
Test Categories and Impact Assessment
| Test File | Tests (approx) | Impact | Reason |
|---|---|---|---|
schemas/index.test.ts |
~20 | NONE | Tests Zod schemas, no UI |
schemas/registry.test.ts |
~15 | NONE | Tests BACKEND_REGISTRY data, no UI |
generators/*.test.ts (3 files) |
~40 | NONE | Tests script generation, no UI |
store/reducer.test.ts |
~20 | NONE | Tests state reducer, no UI |
App.test.tsx |
~10 | LOW | May need ThemeProvider wrapper if test renders App internals directly |
StepIndicator.test.tsx |
~10 | LOW | Tests click behavior and text content, not styles |
BackendSelectionStep.test.tsx |
~15 | LOW | Tests card selection behavior via role queries |
RemoteConfigStep.test.tsx |
~15 | LOW | Tests form submission and validation |
ReviewStep.test.tsx |
~15 | LOW | Tests output generation, copy/download actions |
Key insight: ~60% of tests (95+ tests across schemas, generators, reducer) are pure logic tests with ZERO UI coupling. They will not be affected at all.
For UI tests: Testing Library queries by role, label text, and accessible names -- NOT by CSS class names. Swapping bg-blue-600 to bg-primary does not change what getByRole('button') or getByText('Next') finds. These tests should remain green through the entire migration.
One risk area: If FieldRenderer refactoring changes DOM structure (e.g., wrapping inputs in a new primitive component that adds an extra <div>), tests that use container.querySelector or rely on specific nesting could break. Mitigation: ensure new primitives produce the same semantic DOM (same <input>, <select>, <label> elements with same attributes).
Suggested Build Order (Minimizes Breakage)
Phase 1: CSS Foundation (Zero Test Impact)
What: Set up MD3 tokens and dark mode infrastructure in index.css. No component changes.
- Add
@custom-variant dark (&:where(.dark, .dark *))toindex.css - Define all MD3 color tokens in
@themeblock - Add
.darkoverride block in@layer base - Define elevation shadow tokens and border-radius shape tokens
- Create
ThemeProvider+useThemehook (new files, no existing code touched) - Wire
ThemeProviderintoApp.tsx(outermost wrapper)
Test impact: Zero -- existing tests pass unchanged. Add new unit tests for ThemeProvider.
Dependency: None. Can start immediately.
Phase 2: Primitive Extraction (Additive Only)
What: Create new ui/ primitives without modifying existing components yet.
- Create
ui/Buttonwith MD3 variants (filled, outlined, text) - Create
ui/Inputwith MD3 styling (outline, label, error state, focus ring) - Create
ui/Selectwith MD3 styling - Create
ui/Cardwith MD3 elevation and shape - Create
ThemeTogglecomponent - Create
AppShelllayout component
Test impact: Zero on existing tests. Write new tests for each new component.
Dependency: Phase 1 tokens must be in place for correct color references.
Phase 3: StepIndicator Migration (Low Risk, Isolated)
What: Replace inline styles with Tailwind + MD3 tokens. This is flagged tech debt.
- Replace
style={{ fontWeight: 'bold' }}withclassName="font-bold" - Replace
style={{ color: '#999' }}withclassName="text-on-surface-variant" - Replace
style={{ fontWeight: 'normal' }}withclassName="font-normal" - Apply MD3 shape and color tokens to step indicator layout
Test impact: StepIndicator.test.tsx tests click behavior and text content. Style changes are invisible to these tests. Should pass unchanged.
Dependency: Phase 1 tokens.
Phase 4: Component Token Migration (Core Migration)
What: Replace hardcoded Tailwind colors with semantic MD3 tokens across all components. Integrate new primitives.
BackendCard-- swap color classes using Current-to-Token Mapping, optionally compose with CardFieldRenderer-- delegate rendering to Input/Select primitives (preserve DOM semantics)PasswordField-- swap color classes, optionally use Input internallyDeploymentStep-- swap button and input color classesReviewStep-- swap color classesRemoteConfigStep-- swap color classesBackendSelectionStep-- swap color classes
Test impact: LOW. Color class changes are invisible to Testing Library. If FieldRenderer's DOM structure changes, run tests after each sub-step to catch issues early.
Dependency: Phase 1 tokens + Phase 2 primitives.
Phase 5: Layout Shell and Dark Mode UX
What: AppShell integration, ThemeToggle placement, responsive improvements.
- Extract layout from WizardShell into AppShell component
- Add ThemeToggle to app header area
- Wire up dark mode persistence (already in ThemeProvider)
- Mobile responsiveness passes
- Update
App.test.tsxfor new structure
Test impact: App.test.tsx may need structural updates. Other tests unaffected.
Dependency: All previous phases.
Scalability Considerations
| Concern | Current (v1.2) | Future (accent colors) | Future (multi-theme) |
|---|---|---|---|
| Color tokens | ~20 tokens in @theme | Override --color-primary family via JS document.documentElement.style |
Add named theme classes, swap .theme-blue / .theme-green |
| Theme persistence | localStorage r2b-theme key |
Add r2b-accent key |
Add r2b-theme-name key |
| Bundle size | +0 KB (CSS only) | +~3KB if using @material/material-color-utilities for seed-based palette generation |
Same |
| Performance | CSS variable swap (no React re-render for color changes) | One-time JS computation + CSS variable batch update | Same |
| Token generation | Hand-picked values | Use @material/material-color-utilities themeFromSourceColor() to generate all 29 tokens from one seed hex |
Same |
Sources
- Tailwind CSS v4 Dark Mode documentation -- HIGH confidence, official docs
- Material Design 3 Color Roles -- HIGH confidence, official spec
- Material Design 3 Design Tokens -- HIGH confidence, official spec
- Material Design 3 Elevation Tokens -- HIGH confidence, official spec
- Tailwind v4 dark mode @custom-variant discussion -- MEDIUM confidence, community verified pattern
- @material/material-color-utilities npm -- HIGH confidence, official Google package
- Generating MD3 Dynamic Color with JavaScript -- MEDIUM confidence, verified implementation walkthrough
- React dark mode with Context + Tailwind pattern -- MEDIUM confidence, community pattern
- Tailwind v4 @theme with dark mode pattern -- MEDIUM confidence, community implementation