# 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';
---