diff --git a/.planning/phases/08-theme-foundation/08-RESEARCH.md b/.planning/phases/08-theme-foundation/08-RESEARCH.md
new file mode 100644
index 0000000..1db9dc7
--- /dev/null
+++ b/.planning/phases/08-theme-foundation/08-RESEARCH.md
@@ -0,0 +1,600 @@
+# Phase 8: Theme Foundation - Research
+
+**Researched:** 2026-03-31
+**Domain:** Tailwind v4 `@theme` directive, MD3 color tokens, dark mode toggle, FOUC prevention
+**Confidence:** HIGH
+
+---
+
+
+## User Constraints (from CONTEXT.md)
+
+### Locked Decisions
+
+- **Color palette scope:** Essential roles only (~10 tokens): `primary`, `on-primary`, `surface`, `on-surface`, `surface-variant`, `surface-container`, `on-surface-variant`, `outline`, `error`, `on-error`
+- **Primary hue:** Indigo/Purple (#4338CA light, #A5B4FC dark)
+- **Neutral gray surfaces** (no tinted surfaces) — primary color only on interactive elements
+- **Migrate all 63 existing hardcoded color classes** to semantic tokens in Phase 8 (10 files), not deferred to Phase 9
+
+- **Theme toggle position:** Top-right corner of the app, next to the "Ready2Blob" title
+- **Theme toggle style:** Segmented control with icons + labels [ Sun Light | Moon Dark | Monitor System ]
+- **Default for first-time visitors:** System (respects OS preference)
+- **DOM class toggle on ``**, not React Context (already decided in STATE.md)
+
+- **Flash prevention:** Inline blocking script in `` of index.html — reads localStorage before CSS paints
+- **localStorage key:** `r2b-theme` (namespaced to avoid conflicts)
+- **Values:** `"light"` | `"dark"` | `"system"` — absent treated as system
+- **Dark class applied to `` element** (Tailwind v4 convention)
+- **No real-time OS theme sync** — system preference checked only on page load
+- **No matchMedia change listener** (user must reload if OS theme changes mid-session)
+
+- **Zero new runtime dependencies** — Tailwind v4 `@theme` + CSS custom properties only
+
+### Claude's Discretion
+
+- Token naming convention (MD3 canonical vs simplified)
+- Token file structure (single file vs split)
+- Exact `@theme` directive mapping syntax
+- Segmented control component implementation details
+- Exact indigo/purple shade values and dark mode variants
+- Transition animation on theme switch (if any)
+
+### Deferred Ideas (OUT OF SCOPE)
+
+None — discussion stayed within phase scope
+
+
+---
+
+
+## Phase Requirements
+
+| ID | Description | Research Support |
+|----|-------------|-----------------|
+| THEME-01 | App uses a consistent MD3 color token system (CSS custom properties) that all components reference instead of hardcoded Tailwind color classes | Tailwind v4 `@theme` maps `--color-*` namespace to utility classes like `bg-surface`, `text-on-primary`; light/dark values via `:root` / `.dark` overrides |
+| THEME-02 | User can toggle between System, Light, and Dark themes, with choice persisted across sessions and no flash of unstyled content on load | `@custom-variant dark` + inline blocking script in `` + `localStorage` key `r2b-theme`; DOM class on `` element |
+
+
+---
+
+## Summary
+
+Phase 8 installs a complete MD3 color token system on top of Tailwind v4's CSS-first configuration. The approach uses no new runtime dependencies: tokens are defined as CSS custom properties in `src/index.css` under `@layer base` (light and dark values), then wired to Tailwind utility classes via `@theme`. A single `@custom-variant dark` declaration switches the dark variant from OS media query to `.dark` class on ``, enabling manual toggling.
+
+Flash prevention is handled by a small inline blocking script injected into `index.html`'s `
`. The script reads `localStorage.getItem('r2b-theme')`, resolves the effective value (`"light"` / `"dark"` / system fallback via `matchMedia`), and adds or removes the `.dark` class before the first CSS paint. The ThemeToggle component is a pure DOM manipulator — it writes to `localStorage` and toggles `.dark` on `document.documentElement` directly, with no React Context or re-render cascade.
+
+The migration work is explicit and mechanical: 63 occurrences across 10 files, mapping known Tailwind gray/blue/red/green/yellow classes to the new semantic token classes. The most important design decision delegated to Claude's discretion is the exact indigo shade values and how to handle the special-case dark code block (`bg-gray-900 text-gray-100` in OutputBlock) which should remain a surface-variant rather than following a primary token.
+
+**Primary recommendation:** Use the two-layer pattern — `@layer base` for light/dark raw values + `@theme` referencing those variables via `var(--*)` — so that a single `.dark` class on `` cascades all token values automatically through the existing utility class usage.
+
+---
+
+## Standard Stack
+
+### Core
+
+| Library | Version | Purpose | Why Standard |
+|---------|---------|---------|--------------|
+| tailwindcss | ^4.2.2 (already installed) | Utility CSS framework + `@theme` directive | Already in project; v4 is CSS-first, no config file needed |
+| @tailwindcss/vite | ^4.2.2 (already installed) | Vite plugin integration | Required by v4 for build-time processing |
+
+### Supporting
+
+No new runtime dependencies required. All functionality is CSS + vanilla JS in the inline script.
+
+### Alternatives Considered
+
+| Instead of | Could Use | Tradeoff |
+|------------|-----------|----------|
+| CSS custom properties + `@theme` | `next-themes` library | Library adds ~3KB runtime, overkill for a Vite/React app without SSR; `next-themes` designed for Next.js hydration |
+| Inline blocking script | `useEffect` on mount | `useEffect` runs after React renders — creates visible flash; inline script is synchronous and blocks paint |
+| DOM class toggle | React Context `ThemeProvider` | Context triggers full subtree re-render; DOM class toggle updates CSS variables in one paint with no React involvement |
+
+**Installation:** None required — dependencies already present.
+
+---
+
+## Architecture Patterns
+
+### Recommended Project Structure
+
+```
+src/
+├── index.css # @import tailwindcss + @layer base tokens + @theme + @custom-variant
+├── components/
+│ ├── ui/
+│ │ ├── ThemeToggle.tsx # New: segmented control [ Sun | Moon | Monitor ]
+│ │ ├── BackendCard.tsx # Migrate: border-blue-600 → border-primary, etc.
+│ │ ├── FieldRenderer.tsx # Migrate: border-gray-300, text-gray-700, etc.
+│ │ └── PasswordField.tsx # Migrate: text-gray-400, hover:text-gray-700, etc.
+│ └── wizard/
+│ ├── AzureAuthToggle.tsx # Migrate: bg-blue-600, bg-white, text-gray-700
+│ ├── SftpAuthToggle.tsx # Migrate: bg-blue-600, bg-white, text-gray-700
+│ ├── DeploymentStep.tsx # Migrate: border-gray-300, hover:bg-gray-50
+│ ├── OutputBlock.tsx # Migrate: bg-gray-900 text-gray-100 → surface-container tokens
+│ ├── RemoteConfigStep.tsx # Migrate: bg-blue-600 text-white, border-gray-300
+│ └── ReviewStep.tsx # Migrate: text-green-700 bg-green-50, bg-yellow-50 border-yellow-300
+index.html # Add inline blocking script in
+```
+
+### Pattern 1: Two-Layer Token Architecture
+
+**What:** Separate raw color values (in `@layer base`) from the `@theme` mapping. Raw values live under `:root` (light) and `.dark` (dark). `@theme` references them with `var(--*)`.
+
+**When to use:** Whenever you need a single class on `` to cascade all color changes — no per-element `dark:` prefixes needed.
+
+**Example:**
+```css
+/* src/index.css */
+@import "tailwindcss";
+
+/* 1. Declare dark variant based on .dark class on */
+@custom-variant dark (&:where(.dark, .dark *));
+
+/* 2. Raw values — light defaults, overridden in .dark */
+@layer base {
+ :root {
+ --r2b-primary: #4338CA; /* indigo-700 */
+ --r2b-on-primary: #FFFFFF;
+ --r2b-surface: #F9FAFB; /* gray-50 */
+ --r2b-on-surface: #111827; /* gray-900 */
+ --r2b-surface-variant: #1F2937; /* gray-800 — code blocks */
+ --r2b-on-surface-variant: #F3F4F6; /* gray-100 — text on code blocks */
+ --r2b-surface-container: #FFFFFF; /* card/form backgrounds */
+ --r2b-on-surface-container: #374151; /* gray-700 — labels, secondary text */
+ --r2b-outline: #D1D5DB; /* gray-300 — borders */
+ --r2b-error: #EF4444; /* red-500 */
+ --r2b-on-error: #FFFFFF;
+ }
+
+ .dark {
+ --r2b-primary: #A5B4FC; /* indigo-300 */
+ --r2b-on-primary: #1E1B4B; /* indigo-950 */
+ --r2b-surface: #111827; /* gray-900 */
+ --r2b-on-surface: #F9FAFB; /* gray-50 */
+ --r2b-surface-variant: #0F172A; /* slate-900 — code blocks */
+ --r2b-on-surface-variant: #E5E7EB; /* gray-200 */
+ --r2b-surface-container: #1F2937; /* gray-800 — card/form backgrounds */
+ --r2b-on-surface-container: #D1D5DB; /* gray-300 — labels, secondary text */
+ --r2b-outline: #4B5563; /* gray-600 — borders */
+ --r2b-error: #FCA5A5; /* red-300 */
+ --r2b-on-error: #7F1D1D; /* red-900 */
+ }
+}
+
+/* 3. Wire to Tailwind utility classes */
+@theme {
+ --color-primary: var(--r2b-primary);
+ --color-on-primary: var(--r2b-on-primary);
+ --color-surface: var(--r2b-surface);
+ --color-on-surface: var(--r2b-on-surface);
+ --color-surface-variant: var(--r2b-surface-variant);
+ --color-on-surface-variant: var(--r2b-on-surface-variant);
+ --color-surface-container: var(--r2b-surface-container);
+ --color-on-surface-container: var(--r2b-on-surface-container);
+ --color-outline: var(--r2b-outline);
+ --color-error: var(--r2b-error);
+ --color-on-error: var(--r2b-on-error);
+}
+```
+
+This generates: `bg-surface`, `text-on-surface`, `bg-surface-container`, `text-on-surface-container`, `bg-primary`, `text-on-primary`, `border-outline`, `bg-error`, `text-error`, `text-on-error`, etc.
+
+### Pattern 2: Flash Prevention Inline Script
+
+**What:** A synchronous script in `` that runs before any CSS is applied. Reads `localStorage`, resolves the effective theme, and applies `.dark` to `` if needed.
+
+**When to use:** Always — any async approach (React `useEffect`, CSS media queries) allows one frame of wrong-theme content.
+
+**Example:**
+```html
+
+
+```
+
+### Pattern 3: ThemeToggle Component
+
+**What:** A stateless React component that reads its current display state from `document.documentElement.classList` + `localStorage`, and writes to both on user interaction.
+
+**When to use:** As the sole place in the codebase that touches theme state.
+
+**Example:**
+```tsx
+// src/components/ui/ThemeToggle.tsx
+type ThemeValue = 'light' | 'dark' | 'system';
+
+const STORAGE_KEY = 'r2b-theme';
+
+function getStored(): ThemeValue {
+ try {
+ const v = localStorage.getItem(STORAGE_KEY);
+ if (v === 'light' || v === 'dark' || v === 'system') return v;
+ } catch {}
+ return 'system';
+}
+
+function applyTheme(value: ThemeValue) {
+ const root = document.documentElement;
+ const prefersDark = window.matchMedia('(prefers-color-scheme: dark)').matches;
+ const isDark = value === 'dark' || (value === 'system' && prefersDark);
+ root.classList.toggle('dark', isDark);
+ try { localStorage.setItem(STORAGE_KEY, value); } catch {}
+}
+
+export function ThemeToggle() {
+ const [theme, setTheme] = useState(getStored);
+
+ function select(value: ThemeValue) {
+ setTheme(value);
+ applyTheme(value);
+ }
+
+ return (
+
+ );
+}
+```
+
+Note: The `useState(getStored)` initializer (lazy init) runs once on mount — avoids localStorage access on every render. `applyTheme` is also called directly (no `useEffect` needed) because DOM mutation is synchronous.
+
+### Pattern 4: Color Class Migration Mapping
+
+**What:** A mechanical find-and-replace map from existing hardcoded Tailwind classes to new semantic tokens.
+
+```
+bg-gray-50 → bg-surface
+bg-white → bg-surface-container
+bg-gray-900 → bg-surface-variant (code blocks only)
+text-gray-900 → text-on-surface
+text-gray-700 → text-on-surface-container
+text-gray-500 → text-on-surface-container (secondary text — same token, or add opacity)
+text-gray-400 → text-on-surface-container (muted — consider opacity variant)
+text-gray-100 → text-on-surface-variant (text on code blocks)
+border-gray-300 → border-outline
+border-gray-200 → border-outline
+hover:bg-gray-50 → hover:bg-surface
+hover:border-blue-400 → hover:border-primary
+bg-blue-600 → bg-primary
+bg-blue-50 → bg-surface (tinted interactive states — use bg-primary/10 instead)
+text-white → text-on-primary
+text-blue-600 → text-primary
+text-blue-500 → text-primary
+text-blue-700 → text-primary
+hover:bg-blue-700 → hover:bg-primary
+border-blue-600 → border-primary
+border-blue-200 → border-primary/30
+text-blue-700 bg-blue-50 border border-blue-200 → tooltip pattern: text-primary bg-surface border-primary/30
+border-red-500 → border-error
+focus:ring-red-300 → focus:ring-error/50
+text-red-600 → text-error
+text-red-500 → text-error
+text-green-700 bg-green-50 → use surface-container + primary (or a dedicated success token)
+bg-yellow-50 border-yellow-300 text-yellow-800 → warning pattern (see Pitfall 2)
+```
+
+### Anti-Patterns to Avoid
+
+- **Using `dark:` utility prefixes after migration:** The whole point of the token system is that `bg-surface` already contains both light and dark values via the CSS variable cascade. Adding `dark:bg-surface-variant` creates duplication and confusion.
+- **Defining tokens directly in `@theme` without `@layer base` indirection:** `@theme { --color-surface: #F9FAFB; }` puts a static value in the generated CSS. You cannot override it with `.dark { --color-surface: #111827; }` because `@theme` emits the value into `:root` directly, not as a reference. You must use `@theme { --color-surface: var(--r2b-surface); }` and define `--r2b-surface` in `@layer base`.
+- **Toggling theme in React state/Context:** Causes a full subtree re-render on every toggle. DOM class on `` is a CSS cascade — zero React overhead.
+
+---
+
+## Don't Hand-Roll
+
+| Problem | Don't Build | Use Instead | Why |
+|---------|-------------|-------------|-----|
+| Dark/light value switching | Per-component `dark:` prefixes on every class | Single `.dark` class on `` + CSS variable cascade | 63 classes become ~10 tokens; adding a new component costs zero extra work |
+| Flash of unstyled content | React `useEffect` checking localStorage | Inline blocking `
+
+
+
+
+
+
+```
+
+### App.tsx header with ThemeToggle
+
+```tsx
+// Updated WizardShell header area — ThemeToggle next to h1
+
+
+
+
Ready2Blob
+
+
+
+ ...
+
+
+```
+
+---
+
+## State of the Art
+
+| Old Approach | Current Approach | When Changed | Impact |
+|--------------|------------------|--------------|--------|
+| `tailwind.config.js` `darkMode: 'class'` | `@custom-variant dark` in CSS | Tailwind v4.0 (2025) | No config file; CSS-first |
+| `extend.colors` in JS config | `@theme { --color-*: ... }` in CSS | Tailwind v4.0 (2025) | Tokens and utilities in one place |
+| Separate `dark:` prefix on every utility | Single CSS variable cascade via `.dark` class | CSS variables + v4 | Zero per-element dark variants needed |
+| `matchMedia` change listener for live sync | Check on page load only | Project decision | Simpler; avoids mid-session inconsistency |
+
+**Deprecated/outdated:**
+- `tailwind.config.js` `darkMode: 'class'` key: replaced by `@custom-variant` in CSS
+- `theme.extend.colors`: replaced by `@theme` block
+
+---
+
+## Open Questions
+
+1. **Warning/success tokens**
+ - What we know: ReviewStep has green (client-side notice) and yellow (security warning) color combinations that have no direct MD3 token equivalent
+ - What's unclear: Should these be added as `--color-warning` / `--color-success` tokens or kept as hardcoded Tailwind classes with `dark:` overrides?
+ - Recommendation: Add `--color-warning` and `--color-on-warning` (amber-based) and `--color-success` and `--color-on-success` (green-based) tokens to keep the codebase clean. Total token count goes from 10 to 14 — still minimal. This keeps THEME-01 fully satisfied.
+
+2. **Transition animation on theme switch**
+ - What we know: Claude's Discretion — user left this open
+ - What's unclear: A CSS `transition: color 150ms, background-color 150ms` on `:root` or `body` would animate all color changes on toggle
+ - Recommendation: Add `transition: background-color 200ms ease, color 200ms ease, border-color 200ms ease` to the `body` in `@layer base`. Disable with `@media (prefers-reduced-motion: reduce)` wrapper. Adds visual polish at zero cost.
+
+3. **`on-surface-container` vs `on-surface` for gray-500/gray-400**
+ - What we know: The codebase uses both `text-gray-700` (labels) and `text-gray-500` (secondary) and `text-gray-400` (muted/icons)
+ - What's unclear: Whether to create separate `--color-muted` token or use opacity modifiers (`text-on-surface/60`)
+ - Recommendation: Use Tailwind's opacity modifier: `text-on-surface-container/70` for secondary text, `text-on-surface-container/50` for muted. No extra tokens needed. This matches how MD3 uses opacity on surface roles.
+
+---
+
+## Validation Architecture
+
+`workflow.nyquist_validation` is `true` — include this section.
+
+### Test Framework
+
+| Property | Value |
+|----------|-------|
+| Framework | Vitest ^4.1.1 |
+| Config file | `vite.config.ts` (test block: `environment: 'jsdom'`) |
+| Quick run command | `npm test` |
+| Full suite command | `npm test` |
+
+Note: `vitest.config.ts` sets `environment: 'node'` and `globals: true`. The component tests override with `// @vitest-environment jsdom` pragma. The `vite.config.ts` also defines `test: { environment: 'jsdom' }`. The component test files win because they use the pragma.
+
+### Phase Requirements → Test Map
+
+| Req ID | Behavior | Test Type | Automated Command | File Exists? |
+|--------|----------|-----------|-------------------|-------------|
+| THEME-01 | Token classes (`bg-surface`, `text-on-primary`, etc.) are used in migrated components instead of hardcoded Tailwind colors | unit | `npm test` (all existing component tests pass) | ✅ existing |
+| THEME-01 | All 63 hardcoded color classes are absent after migration | lint/grep | `grep -r "text-gray-\|bg-gray-\|border-gray-\|bg-blue-\|text-blue-" src/` | manual verification |
+| THEME-02 | ThemeToggle renders with three options (Light, Dark, System) | unit | `npm test -- --reporter=verbose` | ❌ Wave 0 |
+| THEME-02 | Clicking Dark adds `.dark` class to `document.documentElement` | unit | `npm test -- --reporter=verbose` | ❌ Wave 0 |
+| THEME-02 | Theme choice persists in `localStorage` under key `r2b-theme` | unit | `npm test -- --reporter=verbose` | ❌ Wave 0 |
+| THEME-02 | Flash prevention: `dark` class applied before first render | manual | Open browser in dark OS mode, hard reload, observe | manual-only |
+
+### Sampling Rate
+
+- **Per task commit:** `npm test`
+- **Per wave merge:** `npm test`
+- **Phase gate:** Full suite green before `/gsd:verify-work`
+
+### Wave 0 Gaps
+
+- [ ] `src/components/ui/ThemeToggle.test.tsx` — covers THEME-02 (component unit tests)
+
+*(All existing 5 test files cover structural/routing behaviors unaffected by class-name changes — they will continue to pass as-is after migration.)*
+
+---
+
+## Sources
+
+### Primary (HIGH confidence)
+- [Tailwind CSS — Theme variables](https://tailwindcss.com/docs/theme) — `@theme` directive syntax, `--color-*` namespace, `var()` in `@theme`
+- [Tailwind CSS — Dark mode](https://tailwindcss.com/docs/dark-mode) — `@custom-variant dark`, `.dark` class on ``, system preference pattern
+
+### Secondary (MEDIUM confidence)
+- [Tailwind CSS — Multi-theme system (Medium)](https://medium.com/render-beyond/build-a-flawless-multi-theme-ui-using-new-tailwind-css-v4-react-dca2b3c95510) — Two-layer pattern (`:root` raw values + `@theme` var references) verified against official docs
+- [Tailwind CSS theming best practices discussion](https://github.com/tailwindlabs/tailwindcss/discussions/18471) — `@theme inline` vs property-scoped variables
+- [Invertase dark mode blog](https://invertase.io/blog/tailwind-dark-mode) — Inline blocking script pattern; v3 syntax adapted to v4
+
+### Tertiary (LOW confidence)
+- [Dark mode discussion #15083](https://github.com/tailwindlabs/tailwindcss/discussions/15083) — Community patterns for CSS variables in dark/light mode
+
+---
+
+## Metadata
+
+**Confidence breakdown:**
+- Standard stack: HIGH — already installed and in use; no new dependencies
+- Architecture: HIGH — `@theme` + `@custom-variant` patterns verified against official docs
+- Token values: MEDIUM — exact hex values for discretion items (indigo shades, dark variants) are Claude's choices; correctness depends on visual review not API docs
+- Pitfalls: HIGH for `@theme` static-vs-dynamic (verified); MEDIUM for warning/success color gap (project-specific judgment)
+- Migration map: HIGH — mechanical mapping from known Tailwind classes to known token roles
+
+**Research date:** 2026-03-31
+**Valid until:** 2026-09-30 (Tailwind v4 is recently stable; CSS custom properties are CSS3 — very stable)