From abb8320d741750309c3840259b48d79570a1b0fd Mon Sep 17 00:00:00 2001 From: Kawa Date: Tue, 31 Mar 2026 18:00:35 +0200 Subject: [PATCH] =?UTF-8?q?docs(08):=20research=20phase=20=E2=80=94=20Tail?= =?UTF-8?q?wind=20v4=20@theme=20tokens,=20dark=20mode,=20FOUC=20prevention?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Co-Authored-By: Claude Sonnet 4.6 --- .../phases/08-theme-foundation/08-RESEARCH.md | 600 ++++++++++++++++++ 1 file changed, 600 insertions(+) create mode 100644 .planning/phases/08-theme-foundation/08-RESEARCH.md diff --git a/.planning/phases/08-theme-foundation/08-RESEARCH.md b/.planning/phases/08-theme-foundation/08-RESEARCH.md new file mode 100644 index 0000000..1db9dc7 --- /dev/null +++ b/.planning/phases/08-theme-foundation/08-RESEARCH.md @@ -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 (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)