Files
Ready2Blob/.planning/phases/08-theme-foundation/08-RESEARCH.md
T

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 @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 </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

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 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 <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:

  1. 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.
  2. Add two extra tokens: --color-warning and --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.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)

Secondary (MEDIUM confidence)

Tertiary (LOW confidence)


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)