docs(08): research phase — Tailwind v4 @theme tokens, dark mode, FOUC prevention
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
@@ -0,0 +1,600 @@
|
||||
# Phase 8: Theme Foundation - Research
|
||||
|
||||
**Researched:** 2026-03-31
|
||||
**Domain:** Tailwind v4 `@theme` directive, MD3 color tokens, dark mode toggle, FOUC prevention
|
||||
**Confidence:** HIGH
|
||||
|
||||
---
|
||||
|
||||
<user_constraints>
|
||||
## 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
|
||||
|
||||
### 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:**
|
||||
```css
|
||||
/* 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:**
|
||||
```html
|
||||
<!-- 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:**
|
||||
```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<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
|
||||
|
||||
```css
|
||||
/* 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)
|
||||
|
||||
```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
|
||||
|
||||
```tsx
|
||||
// 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)
|
||||
- [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 `<html>`, 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)
|
||||
Reference in New Issue
Block a user