Files

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

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:

  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.

@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

// 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)

  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:

// 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.

  1. Add @custom-variant dark (&:where(.dark, .dark *)) to index.css
  2. Define all MD3 color tokens in @theme block
  3. Add .dark override block in @layer base
  4. Define elevation shadow tokens and border-radius shape tokens
  5. Create ThemeProvider + useTheme hook (new files, no existing code touched)
  6. Wire ThemeProvider into App.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.

  1. Create ui/Button with MD3 variants (filled, outlined, text)
  2. Create ui/Input with MD3 styling (outline, label, error state, focus ring)
  3. Create ui/Select with MD3 styling
  4. Create ui/Card with MD3 elevation and shape
  5. Create ThemeToggle component
  6. Create AppShell layout 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.

  1. Replace style={{ fontWeight: 'bold' }} with className="font-bold"
  2. Replace style={{ color: '#999' }} with className="text-on-surface-variant"
  3. Replace style={{ fontWeight: 'normal' }} with className="font-normal"
  4. 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.

  1. BackendCard -- swap color classes using Current-to-Token Mapping, optionally compose with Card
  2. FieldRenderer -- delegate rendering to Input/Select primitives (preserve DOM semantics)
  3. PasswordField -- swap color classes, optionally use Input internally
  4. DeploymentStep -- swap button and input color classes
  5. ReviewStep -- swap color classes
  6. RemoteConfigStep -- swap color classes
  7. BackendSelectionStep -- 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.

  1. Extract layout from WizardShell into AppShell component
  2. Add ThemeToggle to app header area
  3. Wire up dark mode persistence (already in ThemeProvider)
  4. Mobile responsiveness passes
  5. Update App.test.tsx for 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