Files

594 lines
27 KiB
Markdown

# 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:**
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<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
```typescript
// 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:**
```tsx
// 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:**
```tsx
// 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:**
```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(
<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:**
```tsx
// 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
- [Tailwind CSS v4 Dark Mode documentation](https://tailwindcss.com/docs/dark-mode) -- HIGH confidence, official docs
- [Material Design 3 Color Roles](https://m3.material.io/styles/color/roles) -- HIGH confidence, official spec
- [Material Design 3 Design Tokens](https://m3.material.io/foundations/design-tokens) -- HIGH confidence, official spec
- [Material Design 3 Elevation Tokens](https://m3.material.io/styles/elevation/tokens) -- HIGH confidence, official spec
- [Tailwind v4 dark mode @custom-variant discussion](https://github.com/tailwindlabs/tailwindcss/discussions/15083) -- MEDIUM confidence, community verified pattern
- [@material/material-color-utilities npm](https://www.npmjs.com/package/@material/material-color-utilities) -- HIGH confidence, official Google package
- [Generating MD3 Dynamic Color with JavaScript](https://dt.in.th/M3DynamicColorJS) -- MEDIUM confidence, verified implementation walkthrough
- [React dark mode with Context + Tailwind pattern](https://medium.com/@sandeepshome.dev/react-theming-dark-mode-with-context-api-and-tailwindcss-b3ef50a9522b) -- MEDIUM confidence, community pattern
- [Tailwind v4 @theme with dark mode pattern](https://medium.com/@kevstrosky/theme-colors-with-tailwind-css-v4-0-and-next-themes-dark-light-custom-mode-36dca1e20419) -- MEDIUM confidence, community implementation