# 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 (
{(['light', 'dark', 'system'] as const).map((v) => (
))}
);
}
```
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)