30 KiB
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>
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
<html>, not React Context (already decided in STATE.md) -
Flash prevention: Inline blocking script in
<head>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
<html>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
@themedirective 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 </user_constraints>
<phase_requirements>
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 <head> + localStorage key r2b-theme; DOM class on <html> element |
| </phase_requirements> |
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 <html>, enabling manual toggling.
Flash prevention is handled by a small inline blocking script injected into index.html's <head>. 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 <html> 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 <head>
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 <html> to cascade all color changes — no per-element dark: prefixes needed.
Example:
/* src/index.css */
@import "tailwindcss";
/* 1. Declare dark variant based on .dark class on <html> */
@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 <head> that runs before any CSS is applied. Reads localStorage, resolves the effective theme, and applies .dark to <html> if needed.
When to use: Always — any async approach (React useEffect, CSS media queries) allows one frame of wrong-theme content.
Example:
<!-- index.html — inside <head>, before any stylesheet links -->
<script>
(function() {
try {
var stored = localStorage.getItem('r2b-theme');
var effective;
if (stored === 'dark') {
effective = 'dark';
} else if (stored === 'light') {
effective = 'light';
} else {
// stored === 'system' or absent — check OS preference
effective = window.matchMedia('(prefers-color-scheme: dark)').matches
? 'dark'
: 'light';
}
if (effective === 'dark') {
document.documentElement.classList.add('dark');
}
} catch (e) {
// localStorage unavailable (private mode, etc.) — leave light as default
}
})();
</script>
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:
// 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<ThemeValue>(getStored);
function select(value: ThemeValue) {
setTheme(value);
applyTheme(value);
}
return (
<div role="group" aria-label="Theme" className="flex rounded-md border border-outline overflow-hidden text-sm">
{(['light', 'dark', 'system'] as const).map((v) => (
<button
key={v}
type="button"
onClick={() => select(v)}
aria-pressed={theme === v}
className={theme === v
? 'flex-1 px-3 py-1 bg-primary text-on-primary font-medium'
: 'flex-1 px-3 py-1 bg-surface-container text-on-surface-container hover:bg-surface'}
>
{v === 'light' ? '☀ Light' : v === 'dark' ? '🌙 Dark' : '⊙ System'}
</button>
))}
</div>
);
}
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 thatbg-surfacealready contains both light and dark values via the CSS variable cascade. Addingdark:bg-surface-variantcreates duplication and confusion. - Defining tokens directly in
@themewithout@layer baseindirection:@theme { --color-surface: #F9FAFB; }puts a static value in the generated CSS. You cannot override it with.dark { --color-surface: #111827; }because@themeemits the value into:rootdirectly, not as a reference. You must use@theme { --color-surface: var(--r2b-surface); }and define--r2b-surfacein@layer base. - Toggling theme in React state/Context: Causes a full subtree re-render on every toggle. DOM class on
<html>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 <html> + 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 <script> in <head> |
React lifecycle runs after hydration; useEffect is always async-after-paint |
| System preference detection | Polling matchMedia |
Check once in the inline script + ThemeToggle mount | One read on load is sufficient given no real-time OS sync requirement |
Key insight: The CSS variable indirection layer (--r2b-* raw values → @theme reference via var()) is the unlock. Without it, @theme emits static values and the .dark class override cannot work.
Common Pitfalls
Pitfall 1: Static values in @theme break dark mode override
What goes wrong: Developer writes @theme { --color-surface: #F9FAFB; }. Adds .dark { --color-surface: #111827; } to @layer base. Dark mode has no effect on bg-surface utility.
Why it happens: Tailwind v4's @theme compiles --color-surface: #F9FAFB into :root { --color-surface: #F9FAFB; } at build time — a static value. The .dark override happens at :root + .dark specificity, but the utility class bg-surface uses var(--color-surface), which resolves to the :root value (not overridden because specificity battle). Actually: the real issue is Tailwind generates background-color: var(--color-surface) in the utility, and .dark does override :root correctly — but only if the @theme value itself is var(--r2b-surface) not a static hex. If static hex is used, the :root value is just the hex and .dark { --color-surface: ... } does work. However the @theme directive is design-time only — it cannot contain var() references to dynamic variables in some Tailwind versions.
Verified behavior (Tailwind v4.2.2): The recommended safe pattern confirmed by community and official examples is: keep raw values in @layer base :root / .dark, and use @theme with var() references. Tailwind v4 does support var() in @theme — this is what enables the entire dynamic theming approach.
How to avoid: Always use @theme { --color-X: var(--r2b-X); } with matching @layer base { :root { --r2b-X: <light-value>; } .dark { --r2b-X: <dark-value>; } }.
Warning signs: bg-surface shows the same color in both light and dark mode.
Pitfall 2: Semantic gap for warning/success colors
What goes wrong: ReviewStep uses text-green-700 bg-green-50 (client-side notice) and bg-yellow-50 border-yellow-300 text-yellow-800/text-yellow-900 (security warning). These have no MD3 equivalents in the 10-token set.
Why it happens: MD3 defines error but not success or warning as primary roles.
How to avoid: Two valid approaches:
- Keep these as hardcoded Tailwind classes with
dark:overrides (acceptable for one-off semantic messages). This means those 6 color classes in ReviewStep are intentionally not migrated to tokens. - Add two extra tokens:
--color-warningand--color-on-warning(and optionally--color-success/--color-on-success). Stays within "no new dependencies" constraint. Recommended if consistency across future phases matters.
Warning signs: After migration, the security warning area looks wrong in dark mode (yellow-50 is near-white — invisible against dark surfaces).
Pitfall 3: ThemeToggle reads stale localStorage on HMR / fast refresh
What goes wrong: During development with Vite HMR, the ThemeToggle component re-mounts but getStored() runs again and the localStorage value may not match the current .dark class state (if developer toggled via DevTools).
Why it happens: The inline script runs once on hard reload. HMR does a partial re-mount.
How to avoid: This is a dev-only annoyance. The component reads from localStorage on mount which is correct behavior. In production (hard loads), it will always be consistent. No special handling needed — document it in a code comment.
Pitfall 4: @custom-variant dark placement matters
What goes wrong: @custom-variant dark placed after @import "tailwindcss" in the CSS file may fail to override the built-in dark: variant in some build tool configurations.
Why it happens: Order of processing directives.
How to avoid: Place @custom-variant dark (&:where(.dark, .dark *)); immediately after @import "tailwindcss";, before any @layer or @theme blocks.
Pitfall 5: 131 existing test selectors
What goes wrong: Tests select elements by text content or role, but some tests in BackendSelectionStep, RemoteConfigStep, ReviewStep may also implicitly test rendered output via snapshot or class-based assertions that break when classes change.
Why it happens: Token migration changes class names on DOM elements.
How to avoid: Check existing tests before migrating each file. The 5 test files use screen.getByText, screen.getByRole, and userEvent — not class-based selectors. STATE.md confirms "131 test selectors could break during component restyling" but on inspection the tests use semantic queries. Risk is LOW if migration is class-name-only (no structural DOM changes).
Code Examples
Verified patterns from official sources and community documentation:
Complete index.css structure
/* Source: https://tailwindcss.com/docs/dark-mode + https://tailwindcss.com/docs/theme */
@import "tailwindcss";
/* Override built-in dark variant to use .dark class instead of prefers-color-scheme */
@custom-variant dark (&:where(.dark, .dark *));
/* Raw color values — overridden per theme */
@layer base {
:root {
--r2b-primary: #4338CA;
--r2b-on-primary: #FFFFFF;
--r2b-surface: #F9FAFB;
--r2b-on-surface: #111827;
--r2b-surface-variant: #1F2937;
--r2b-on-surface-variant: #F3F4F6;
--r2b-surface-container: #FFFFFF;
--r2b-on-surface-container: #374151;
--r2b-outline: #D1D5DB;
--r2b-error: #EF4444;
--r2b-on-error: #FFFFFF;
}
.dark {
--r2b-primary: #A5B4FC;
--r2b-on-primary: #1E1B4B;
--r2b-surface: #111827;
--r2b-on-surface: #F9FAFB;
--r2b-surface-variant: #0F172A;
--r2b-on-surface-variant: #E5E7EB;
--r2b-surface-container: #1F2937;
--r2b-on-surface-container: #D1D5DB;
--r2b-outline: #4B5563;
--r2b-error: #FCA5A5;
--r2b-on-error: #7F1D1D;
}
}
/* 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);
}
Flash prevention script (index.html)
<!-- Source: pattern verified against https://tailwindcss.com/docs/dark-mode -->
<!doctype html>
<html lang="en">
<head>
<meta charset="UTF-8" />
<link rel="icon" type="image/svg+xml" href="/vite.svg" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<title>Ready2Blob</title>
<script>
(function() {
try {
var stored = localStorage.getItem('r2b-theme');
var effective;
if (stored === 'dark') {
effective = 'dark';
} else if (stored === 'light') {
effective = 'light';
} else {
effective = window.matchMedia('(prefers-color-scheme: dark)').matches
? 'dark'
: 'light';
}
if (effective === 'dark') {
document.documentElement.classList.add('dark');
}
} catch (e) {}
})();
</script>
</head>
<body>
<div id="root"></div>
<script type="module" src="/src/main.tsx"></script>
</body>
</html>
App.tsx header with ThemeToggle
// Updated WizardShell header area — ThemeToggle next to h1
<div className="min-h-screen bg-surface flex flex-col items-center py-12 px-4">
<div className="w-full max-w-2xl">
<div className="flex items-center justify-between mb-8">
<h1 className="text-3xl font-bold text-on-surface">Ready2Blob</h1>
<ThemeToggle />
</div>
<StepIndicator />
...
</div>
</div>
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.jsdarkMode: 'class'key: replaced by@custom-variantin CSStheme.extend.colors: replaced by@themeblock
Open Questions
-
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-successtokens or kept as hardcoded Tailwind classes withdark:overrides? - Recommendation: Add
--color-warningand--color-on-warning(amber-based) and--color-successand--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.
-
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 150mson:rootorbodywould animate all color changes on toggle - Recommendation: Add
transition: background-color 200ms ease, color 200ms ease, border-color 200ms easeto thebodyin@layer base. Disable with@media (prefers-reduced-motion: reduce)wrapper. Adds visual polish at zero cost.
-
on-surface-containervson-surfacefor gray-500/gray-400- What we know: The codebase uses both
text-gray-700(labels) andtext-gray-500(secondary) andtext-gray-400(muted/icons) - What's unclear: Whether to create separate
--color-mutedtoken or use opacity modifiers (text-on-surface/60) - Recommendation: Use Tailwind's opacity modifier:
text-on-surface-container/70for secondary text,text-on-surface-container/50for muted. No extra tokens needed. This matches how MD3 uses opacity on surface roles.
- What we know: The codebase uses both
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 —
@themedirective syntax,--color-*namespace,var()in@theme - Tailwind CSS — Dark mode —
@custom-variant dark,.darkclass on<html>, system preference pattern
Secondary (MEDIUM confidence)
- Tailwind CSS — Multi-theme system (Medium) — Two-layer pattern (
:rootraw values +@themevar references) verified against official docs - Tailwind CSS theming best practices discussion —
@theme inlinevs property-scoped variables - Invertase dark mode blog — Inline blocking script pattern; v3 syntax adapted to v4
Tertiary (LOW confidence)
- Dark mode discussion #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-variantpatterns 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
@themestatic-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)