# 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 | +---------------------+ | 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 | `` 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:** 1. On app mount, `ThemeProvider` reads `localStorage.getItem('r2b-theme')` 2. If no stored preference, checks `window.matchMedia('(prefers-color-scheme: dark)').matches` 3. Sets/removes `.dark` class on `document.documentElement` 4. Tailwind's `@custom-variant dark` activates `dark:` prefix utilities 5. CSS custom properties in `.dark` scope override light palette values 6. All components using semantic tokens (`bg-surface`, `text-on-surface`) update automatically **Color token flow:** 1. `index.css` defines MD3 color tokens as CSS custom properties in `@theme` 2. Light values are defaults; `.dark` class overrides with dark palette values 3. Tailwind v4 maps these to utility classes (e.g., `--color-surface` becomes `bg-surface`) 4. 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. ```css @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) 1. **No dependency on MD3 web components** -- `@material/web` is Angular/Lit-oriented and would fight React + Tailwind. The MD3 spec is a *design system*, not a library requirement. 2. **Tailwind v4 @theme is the native mechanism** -- tokens defined in `@theme` become first-class Tailwind utilities (`bg-surface`, `text-on-primary`, `shadow-elevation-2`). No plugins needed. 3. **Dark mode is a CSS variable swap** -- the `.dark` class override block is all that's needed. No JS re-rendering, no theme prop drilling to every component. 4. **Accent color extensibility** -- to support user-selectable accent colors later, just override `--color-primary` and related tokens at runtime via JS on `document.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 ```typescript // 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(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(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 ( {children} ); } export function useTheme(): ThemeContextValue { const ctx = useContext(ThemeContext); if (!ctx) throw new Error('useTheme must be used inside '); return ctx; } ``` ### Provider Nesting in App.tsx ```typescript // ThemeProvider wraps WizardProvider -- theme is app-global, wizard state is feature-scoped export default function App() { return ( ); } ``` **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) 1. **Primitives are the reuse boundary** -- Input, Button, Card are used by multiple wizard steps. Fix once, propagate everywhere. 2. **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. 3. **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: 1. **Replace hardcoded colors with semantic tokens** using the Current-to-Token Mapping table above 2. **Add `dark:` variants only where semantic tokens alone are insufficient** (should be rare -- the CSS variable swap handles most cases automatically) 3. **Replace inline styles with Tailwind classes** (StepIndicator specific) 4. **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:** ```tsx // WRONG -- requires dark: override on every element // AFTER: Reusable Button primitive ``` ### 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:** ```tsx // 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( {ui} ); } ``` ### 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:** ```tsx // Card at rest
// Card hovered / elevated
``` ## 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 (``, ``). **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 `
`), tests that use `container.querySelector` or rely on specific nesting could break. Mitigation: ensure new primitives produce the same semantic DOM (same ``, `