# Theme System Architecture ## WorkRoot IT Solutions — Dark/Light Mode Implementation Plan **Author:** frontend-specialist **Date:** 2026-03-21 **Status:** Architecture Draft — Ready for Implementation **Priority:** High --- ## Table of Contents 1. [Overview & Strategy](#1-overview--strategy) 2. [Color Token Design](#2-color-token-design) 3. [CSS Custom Properties Structure](#3-css-custom-properties-structure) 4. [Tailwind Configuration](#4-tailwind-configuration) 5. [FOUC Prevention (No Flash)](#5-fouc-prevention-no-flash) 6. [Theme Switching Logic](#6-theme-switching-logic) 7. [Component Migration Guide](#7-component-migration-guide) 8. [Accessibility & WCAG Compliance](#8-accessibility--wcag-compliance) 9. [Performance Considerations](#9-performance-considerations) 10. [Implementation Phases](#10-implementation-phases) --- ## 1. Overview & Strategy ### Approach: CSS Custom Properties + Tailwind `darkMode: 'class'` The chosen strategy combines two mechanisms: 1. **CSS Custom Properties** (semantic tokens) — define abstract color names like `--color-surface` that change value per theme. All components reference tokens, never raw colors. 2. **Tailwind `darkMode: 'class'`** — the `dark` class on `` gates dark-specific Tailwind utilities. Works with SSR (no hydration mismatch). **Why not `darkMode: 'media'`?** Media-based detection cannot be overridden by user preference. Class-based allows: system detection → user preference override → localStorage persistence. This is the industry standard (Tailwind docs, Radix UI, shadcn/ui). ### Theme Toggle Flow ``` Page Load ├── Read localStorage('theme') │ ├── 'dark' → add class="dark" to │ ├── 'light' → remove class="dark" │ └── null/undefined → check prefers-color-scheme │ ├── dark → add class="dark" │ └── light → no class (default light) │ └── User clicks toggle ├── Toggle class="dark" on └── Write localStorage('theme') = 'dark' | 'light' ``` --- ## 2. Color Token Design ### Current Palette (Light Mode Baseline) The existing system uses three palettes from `tailwind.config.mjs`: | Palette | Base | Usage | |---------|------|-------| | `primary` | `#0891b2` (Cyan-600) | Interactive elements, CTAs, links | | `secondary` | `#1e293b` (Slate-800) | Text, backgrounds, borders | | `accent` | `#f59e0b` (Amber-500) | Highlights, badges, warnings | ### Semantic Token Mapping Rather than exposing raw palette values, components consume **semantic tokens**: #### Surface Tokens (Backgrounds) | Token | Light Value | Dark Value | Usage | |-------|-------------|------------|-------| | `--surface-base` | `#ffffff` | `#0f172a` (slate-950) | Page background | | `--surface-raised` | `#f8fafc` (slate-50) | `#1e293b` (slate-800) | Cards, panels | | `--surface-overlay` | `#f1f5f9` (slate-100) | `#334155` (slate-700) | Hover states, nav | | `--surface-sunken` | `#e2e8f0` (slate-200) | `#0f172a` (slate-950) | Input backgrounds | | `--surface-inverse` | `#0f172a` (slate-950) | `#f8fafc` (slate-50) | Dark sections in light mode | #### Text Tokens | Token | Light Value | Dark Value | WCAG Ratio (Dark) | |-------|-------------|------------|-------------------| | `--text-primary` | `#0f172a` (slate-950) | `#f1f5f9` (slate-100) | 16.7:1 ✅ AAA | | `--text-secondary` | `#475569` (slate-600) | `#94a3b8` (slate-400) | 4.7:1 ✅ AA | | `--text-muted` | `#94a3b8` (slate-400) | `#64748b` (slate-500) | 3.2:1 ⚠️ AA Large only | | `--text-inverse` | `#ffffff` | `#0f172a` (slate-950) | High contrast | | `--text-link` | `#0891b2` (cyan-600) | `#22d3ee` (cyan-400) | 4.5:1 ✅ AA | | `--text-link-hover` | `#0e7490` (cyan-700) | `#67e8f9` (cyan-300) | 5.9:1 ✅ AA | #### Border Tokens | Token | Light Value | Dark Value | Usage | |-------|-------------|------------|-------| | `--border-subtle` | `#e2e8f0` (slate-200) | `#1e293b` (slate-800) | Cards, dividers | | `--border-default` | `#cbd5e1` (slate-300) | `#334155` (slate-700) | Inputs, panels | | `--border-strong` | `#94a3b8` (slate-400) | `#475569` (slate-600) | Focused elements | | `--border-interactive` | `#0891b2` (cyan-600) | `#0891b2` (cyan-600) | Active/focus rings | #### Brand/Interactive Tokens | Token | Light Value | Dark Value | Notes | |-------|-------------|------------|-------| | `--brand-primary` | `#0891b2` | `#0891b2` | Same — primary color unchanged | | `--brand-primary-hover` | `#0e7490` | `#0e7490` | Same hover | | `--brand-primary-subtle` | `#ecfeff` (cyan-50) | `rgba(8,145,178,0.15)` | Tinted bg for badges | | `--brand-primary-text` | `#0e7490` (cyan-700) | `#22d3ee` (cyan-400) | Text on subtle bg | | `--brand-accent` | `#f59e0b` | `#fbbf24` (amber-400) | Amber slightly lighter dark | | `--brand-accent-subtle` | `#fffbeb` (amber-50) | `rgba(245,158,11,0.15)` | Tinted bg | #### Shadow Tokens (Dark Mode Adjustment) Shadows are lighter-opacity in dark mode (dark surfaces don't need heavy shadows): | Token | Light Value | Dark Value | |-------|-------------|------------| | `--shadow-sm` | `0 1px 2px rgba(0,0,0,0.05)` | `0 1px 2px rgba(0,0,0,0.3)` | | `--shadow-md` | `0 4px 6px rgba(0,0,0,0.1)` | `0 4px 6px rgba(0,0,0,0.4)` | | `--shadow-lg` | `0 10px 15px rgba(0,0,0,0.1)` | `0 10px 15px rgba(0,0,0,0.5)` | | `--shadow-card` | `0 4px 6px rgba(0,0,0,0.05)` | `0 0 0 1px rgba(255,255,255,0.08)` | > **Note:** In dark mode, borders often replace shadows for depth perception. The `--shadow-card` dark value uses a subtle border-like ring instead. --- ## 3. CSS Custom Properties Structure ### `src/styles/global.css` — Additions ```css /* ============================================================ THEME TOKENS — Single source of truth for theme-aware colors Light mode (default) values defined on :root Dark mode overrides on :root.dark (html.dark) ============================================================ */ :root { /* Surface */ --surface-base: #ffffff; --surface-raised: #f8fafc; --surface-overlay: #f1f5f9; --surface-sunken: #e2e8f0; --surface-inverse: #0f172a; /* Text */ --text-primary: #0f172a; --text-secondary: #475569; --text-muted: #94a3b8; --text-inverse: #ffffff; --text-link: #0891b2; --text-link-hover: #0e7490; /* Borders */ --border-subtle: #e2e8f0; --border-default: #cbd5e1; --border-strong: #94a3b8; --border-interactive: #0891b2; /* Brand */ --brand-primary: #0891b2; --brand-primary-hover: #0e7490; --brand-primary-subtle: #ecfeff; --brand-primary-text: #0e7490; --brand-accent: #f59e0b; --brand-accent-subtle: #fffbeb; --brand-accent-text: #b45309; /* Shadows */ --shadow-sm: 0 1px 2px 0 rgb(0 0 0 / 0.05); --shadow-md: 0 4px 6px -1px rgb(0 0 0 / 0.1), 0 2px 4px -2px rgb(0 0 0 / 0.1); --shadow-lg: 0 10px 15px -3px rgb(0 0 0 / 0.1), 0 4px 6px -4px rgb(0 0 0 / 0.1); --shadow-card: 0 4px 6px -1px rgb(0 0 0 / 0.05), 0 2px 4px -2px rgb(0 0 0 / 0.05); --shadow-primary: 0 20px 25px -5px rgb(8 145 178 / 0.3); /* Scrollbar */ --scrollbar-track: #f1f5f9; --scrollbar-thumb: #cbd5e1; --scrollbar-thumb-hover: #94a3b8; } /* Dark Mode Overrides */ :root.dark { /* Surface */ --surface-base: #0f172a; --surface-raised: #1e293b; --surface-overlay: #334155; --surface-sunken: #0f172a; --surface-inverse: #f8fafc; /* Text */ --text-primary: #f1f5f9; --text-secondary: #94a3b8; --text-muted: #64748b; --text-inverse: #0f172a; --text-link: #22d3ee; --text-link-hover: #67e8f9; /* Borders */ --border-subtle: #1e293b; --border-default: #334155; --border-strong: #475569; --border-interactive: #0891b2; /* Brand (primary unchanged, accent slightly lighter) */ --brand-primary: #0891b2; --brand-primary-hover: #0e7490; --brand-primary-subtle: rgb(8 145 178 / 0.15); --brand-primary-text: #22d3ee; --brand-accent: #fbbf24; --brand-accent-subtle: rgb(245 158 11 / 0.15); --brand-accent-text: #fcd34d; /* Shadows (heavier + border-substitute for cards) */ --shadow-sm: 0 1px 2px 0 rgb(0 0 0 / 0.3); --shadow-md: 0 4px 6px -1px rgb(0 0 0 / 0.4), 0 2px 4px -2px rgb(0 0 0 / 0.3); --shadow-lg: 0 10px 15px -3px rgb(0 0 0 / 0.5), 0 4px 6px -4px rgb(0 0 0 / 0.4); --shadow-card: 0 0 0 1px rgb(255 255 255 / 0.08); --shadow-primary: 0 20px 25px -5px rgb(8 145 178 / 0.4); /* Scrollbar */ --scrollbar-track: #1e293b; --scrollbar-thumb: #334155; --scrollbar-thumb-hover: #475569; } ``` --- ## 4. Tailwind Configuration ### Changes to `tailwind.config.mjs` ```js export default { darkMode: 'class', // ADD THIS — enables .dark class strategy content: ['./src/**/*.{astro,html,js,jsx,md,mdx,svelte,ts,tsx,vue}'], theme: { extend: { // ... existing colors stay as-is ... // ADD: Semantic color aliases using CSS custom properties // These allow `bg-surface`, `text-text-primary`, etc. in Tailwind classes colors: { // ... existing primary/secondary/accent palettes ... // Semantic theme-aware colors surface: { base: 'var(--surface-base)', raised: 'var(--surface-raised)', overlay: 'var(--surface-overlay)', sunken: 'var(--surface-sunken)', inverse: 'var(--surface-inverse)', }, 'theme-text': { primary: 'var(--text-primary)', secondary: 'var(--text-secondary)', muted: 'var(--text-muted)', inverse: 'var(--text-inverse)', link: 'var(--text-link)', }, 'theme-border': { subtle: 'var(--border-subtle)', DEFAULT: 'var(--border-default)', strong: 'var(--border-strong)', interactive: 'var(--border-interactive)', }, brand: { primary: 'var(--brand-primary)', 'primary-subtle': 'var(--brand-primary-subtle)', 'primary-text': 'var(--brand-primary-text)', accent: 'var(--brand-accent)', 'accent-subtle': 'var(--brand-accent-subtle)', 'accent-text': 'var(--brand-accent-text)', }, }, }, }, }; ``` > **Migration note:** Existing classes like `bg-white`, `text-secondary-800` continue to work unchanged. The new semantic tokens are additive — migrate components incrementally using `bg-surface-base`, `text-theme-text-primary`, etc. --- ## 5. FOUC Prevention (No Flash) ### The Problem On page load, the browser renders HTML before JavaScript runs. Without a synchronous theme script: 1. Page renders in light mode (default CSS) 2. JS reads localStorage, applies `dark` class 3. Page flashes light → dark ### Solution: Inline Blocking Script in `` Add this **before any stylesheets** in `BaseLayout.astro`: ```html ``` **Placement in `BaseLayout.astro`:** ```html ``` **Why `is:inline`?** Astro's `is:inline` directive keeps the script un-processed, ensuring it runs synchronously as a render-blocking script — exactly what we need to read localStorage before paint. **Why IIFE?** Scope isolation. No global variable pollution. **Why `try/catch`?** `localStorage` throws in some private browsing contexts. Graceful fallback to light mode. --- ## 6. Theme Switching Logic ### ThemeToggle Component: `src/components/ThemeToggle.astro` ```astro --- // ThemeToggle.astro // Renders a sun/moon toggle button that persists theme to localStorage --- ``` ### Integration in `Header.astro` Add `` in the desktop CTA section and mobile menu footer: ```astro --- import ThemeToggle from './ThemeToggle.astro'; ---
Appearance
...
``` ### Advanced: Smooth Theme Transition (Optional Enhancement) Add to `global.css` to smooth the color transition when toggling (not on initial load, to avoid FOUC): ```css /* Applied by JS after initial load to enable smooth transitions */ html.theme-transitions, html.theme-transitions *, html.theme-transitions *::before, html.theme-transitions *::after { transition: background-color 200ms ease, color 200ms ease, border-color 200ms ease !important; } ``` ```js // In ThemeToggle script — enable transitions after first interaction let transitionsEnabled = false; btn?.addEventListener('click', () => { if (!transitionsEnabled) { document.documentElement.classList.add('theme-transitions'); transitionsEnabled = true; } setTheme(getTheme() === 'dark' ? 'light' : 'dark'); }); ``` --- ## 7. Component Migration Guide ### Migration Priority Components are categorized by migration effort: #### Tier 1 — Critical Path (Header, Footer, BaseLayout) These affect every page. Migrate first. | Component | Current Class | Migrate To | |-----------|--------------|------------| | `Header.astro` | `bg-white/95` | `bg-surface-base/95` | | `Header.astro` | `border-secondary-100` | `border-theme-border-subtle` | | `Header.astro` | `text-secondary-600` | `text-theme-text-secondary` | | `Header.astro` | `bg-white` (mobile panel) | `bg-surface-raised` | | `BaseLayout.astro` | `bg-white` (body) | `bg-surface-base` | | `BaseLayout.astro` | `text-secondary-800` (body) | `text-theme-text-primary` | #### Tier 2 — Shared Components (Card, Badge, SectionHeader) Reusable components; high leverage. | Component | Pattern | Migration | |-----------|---------|-----------| | `Card.astro` | `bg-white border-secondary-100` | `bg-surface-raised border-theme-border-subtle` | | `Badge.astro` | `bg-primary-50 text-primary-700` | `bg-brand-primary-subtle text-brand-primary-text` | | `SectionHeader.astro` | `text-secondary-900` | `text-theme-text-primary` | #### Tier 3 — Page Sections Individual page heroes, sections. Migrate last. **Dark sections (hero gradients) stay as-is** — they already use `bg-secondary-900` etc. which works in both modes. Only light-mode sections (white/slate-50 backgrounds) need migration. ### The Two-Pattern Rule Every component that renders differently per theme should use **one of two patterns**: **Pattern A: CSS Variables (preferred for complex components)** ```html
``` **Pattern B: Tailwind dark: variants (for simple overrides)** ```html
``` > **Rule:** Prefer Pattern A (semantic tokens) for new and refactored components. Use Pattern B only for quick one-off overrides during migration. Do not mix patterns in the same component. ### Component: `global.css` Class Migrations Key utility classes need dark variants: ```css /* BEFORE */ .card { @apply bg-white rounded-2xl border-2 border-secondary-100 transition-all duration-500; } /* AFTER */ .card { @apply bg-surface-raised rounded-2xl border-2 border-theme-border-subtle transition-all duration-500; } ``` ```css /* BEFORE */ .form-input { @apply ... border-secondary-300 ... text-secondary-900 placeholder-secondary-400 bg-white; } /* AFTER */ .form-input { @apply ... border-theme-border-default ... text-theme-text-primary placeholder-theme-text-muted bg-surface-sunken; } ``` ```css /* Scrollbar — migrate to CSS var tokens */ ::-webkit-scrollbar-track { background: var(--scrollbar-track); } ::-webkit-scrollbar-thumb { background: var(--scrollbar-thumb); border-radius: 5px; } ::-webkit-scrollbar-thumb:hover { background: var(--scrollbar-thumb-hover); } ``` --- ## 8. Accessibility & WCAG Compliance ### Contrast Ratio Reference All token pairs must meet WCAG 2.1 AA (4.5:1 normal text, 3:1 large text/UI). #### Light Mode Pairs | Foreground Token | Background Token | Foreground Hex | Background Hex | Ratio | Level | |-----------------|-----------------|----------------|----------------|-------|-------| | `--text-primary` | `--surface-base` | `#0f172a` | `#ffffff` | **17.7:1** | AAA ✅ | | `--text-secondary` | `--surface-base` | `#475569` | `#ffffff` | **7.0:1** | AAA ✅ | | `--text-muted` | `--surface-base` | `#94a3b8` | `#ffffff` | **3.0:1** | AA Large ⚠️ | | `--text-primary` | `--surface-raised` | `#0f172a` | `#f8fafc` | **17.2:1** | AAA ✅ | | `--brand-primary-text` | `--brand-primary-subtle` | `#0e7490` | `#ecfeff` | **5.2:1** | AA ✅ | | white | `--brand-primary` | `#ffffff` | `#0891b2` | **4.6:1** | AA ✅ | #### Dark Mode Pairs | Foreground Token | Background Token | Foreground Hex | Background Hex | Ratio | Level | |-----------------|-----------------|----------------|----------------|-------|-------| | `--text-primary` | `--surface-base` | `#f1f5f9` | `#0f172a` | **16.7:1** | AAA ✅ | | `--text-secondary` | `--surface-base` | `#94a3b8` | `#0f172a` | **8.5:1** | AAA ✅ | | `--text-muted` | `--surface-base` | `#64748b` | `#0f172a` | **4.7:1** | AA ✅ | | `--text-primary` | `--surface-raised` | `#f1f5f9` | `#1e293b` | **11.2:1** | AAA ✅ | | `--text-link` | `--surface-base` | `#22d3ee` | `#0f172a` | **9.8:1** | AAA ✅ | | `--brand-primary-text` | `--brand-primary-subtle` | `#22d3ee` | `rgba(8,145,178,0.15)≈#0f1e21` | **~9.1:1** | AAA ✅ | | white | `--brand-primary` | `#ffffff` | `#0891b2` | **4.6:1** | AA ✅ | > **Note on `--text-muted` in light mode:** At 3.0:1 ratio, it only passes WCAG AA for large text (18pt+ or 14pt bold). Use `--text-muted` only for supplementary/decorative text, never for primary content. This matches its intended purpose. ### Keyboard & Focus The `ThemeToggle` button: - Uses semantic `