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