# 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 `<html>` 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 <html>
  │     ├── '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 <html>
        └── 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 `<head>`

Add this **before any stylesheets** in `BaseLayout.astro`:

```html
<!-- THEME INIT: Must be inline and blocking to prevent flash -->
<script is:inline>
  (function() {
    try {
      var stored = localStorage.getItem('theme');
      var prefersDark = window.matchMedia('(prefers-color-scheme: dark)').matches;

      if (stored === 'dark' || (!stored && prefersDark)) {
        document.documentElement.classList.add('dark');
      } else {
        document.documentElement.classList.remove('dark');
      }
    } catch (e) {
      // localStorage blocked (private browsing) — fall through to light mode
    }
  })();
</script>
```

**Placement in `BaseLayout.astro`:**

```html
<head>
  <meta charset="UTF-8" />
  <!-- ... other meta tags ... -->

  <!-- MUST be first script, before any stylesheets load -->
  <script is:inline>
    (function() {
      try {
        var s = localStorage.getItem('theme');
        var p = window.matchMedia('(prefers-color-scheme: dark)').matches;
        if (s === 'dark' || (!s && p)) document.documentElement.classList.add('dark');
      } catch(e) {}
    })();
  </script>

  <!-- Critical CSS (existing) -->
  <style is:inline>...</style>
```

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

<button
  id="theme-toggle"
  type="button"
  aria-label="Toggle dark mode"
  aria-pressed="false"
  class="theme-toggle-btn relative w-10 h-10 flex items-center justify-center rounded-lg text-theme-text-secondary hover:text-theme-text-primary hover:bg-surface-overlay transition-colors duration-200 focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-brand-primary focus-visible:ring-offset-2"
>
  <!-- Sun icon (shown in dark mode — click to go light) -->
  <svg
    class="sun-icon w-5 h-5 hidden dark:block"
    xmlns="http://www.w3.org/2000/svg"
    fill="none"
    viewBox="0 0 24 24"
    stroke="currentColor"
    stroke-width="2"
    aria-hidden="true"
  >
    <path stroke-linecap="round" stroke-linejoin="round" d="M12 3v1m0 16v1m9-9h-1M4 12H3m15.364 6.364l-.707-.707M6.343 6.343l-.707-.707m12.728 0l-.707.707M6.343 17.657l-.707.707M16 12a4 4 0 11-8 0 4 4 0 018 0z" />
  </svg>

  <!-- Moon icon (shown in light mode — click to go dark) -->
  <svg
    class="moon-icon w-5 h-5 block dark:hidden"
    xmlns="http://www.w3.org/2000/svg"
    fill="none"
    viewBox="0 0 24 24"
    stroke="currentColor"
    stroke-width="2"
    aria-hidden="true"
  >
    <path stroke-linecap="round" stroke-linejoin="round" d="M20.354 15.354A9 9 0 018.646 3.646 9.003 9.003 0 0012 21a9.003 9.003 0 008.354-5.646z" />
  </svg>
</button>

<script>
  const btn = document.getElementById('theme-toggle');
  const html = document.documentElement;

  function getTheme(): 'dark' | 'light' {
    return html.classList.contains('dark') ? 'dark' : 'light';
  }

  function setTheme(theme: 'dark' | 'light') {
    if (theme === 'dark') {
      html.classList.add('dark');
    } else {
      html.classList.remove('dark');
    }
    try {
      localStorage.setItem('theme', theme);
    } catch (e) {}
    // Update aria-pressed for screen readers
    btn?.setAttribute('aria-pressed', theme === 'dark' ? 'true' : 'false');
    // Dispatch event for other components that need to respond
    window.dispatchEvent(new CustomEvent('themechange', { detail: { theme } }));
  }

  // Sync initial aria-pressed state
  btn?.setAttribute('aria-pressed', getTheme() === 'dark' ? 'true' : 'false');

  // Toggle on click
  btn?.addEventListener('click', () => {
    setTheme(getTheme() === 'dark' ? 'light' : 'dark');
  });

  // Listen for OS-level preference changes (user changes system setting)
  window.matchMedia('(prefers-color-scheme: dark)').addEventListener('change', (e) => {
    // Only auto-switch if user hasn't set an explicit preference
    const stored = (() => { try { return localStorage.getItem('theme'); } catch { return null; } })();
    if (!stored) {
      setTheme(e.matches ? 'dark' : 'light');
    }
  });
</script>
```

### Integration in `Header.astro`

Add `<ThemeToggle />` in the desktop CTA section and mobile menu footer:

```astro
---
import ThemeToggle from './ThemeToggle.astro';
---

<!-- Desktop CTA area (existing) -->
<div class="hidden lg:flex items-center gap-4">
  <ThemeToggle />
  <a href="/contact" class="btn-primary text-sm">Get Started ...</a>
</div>

<!-- Mobile menu footer (existing) -->
<div class="p-4 border-t border-theme-border-subtle">
  <div class="flex items-center justify-between mb-3">
    <span class="text-sm text-theme-text-muted">Appearance</span>
    <ThemeToggle />
  </div>
  <a href="/contact" class="btn-primary w-full justify-center">...</a>
</div>
```

### 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
<!-- Component uses tokens directly -->
<div class="bg-surface-raised border border-theme-border-subtle text-theme-text-primary">
```

**Pattern B: Tailwind dark: variants (for simple overrides)**
```html
<!-- Use dark: prefix for one-off overrides -->
<div class="bg-white dark:bg-slate-800 text-slate-900 dark:text-slate-100">
```

> **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 `<button>` element (keyboard accessible automatically)
- Has `aria-label="Toggle dark mode"`
- Has `aria-pressed` reflecting current state
- Uses `focus-visible:ring-2` for keyboard focus ring
- No tabindex manipulation needed

### Announcements for Screen Readers

Optionally add a live region to announce theme changes:

```html
<!-- In BaseLayout.astro, near end of <body> -->
<div
  id="theme-announcement"
  role="status"
  aria-live="polite"
  aria-atomic="true"
  class="sr-only"
></div>
```

```js
// In ThemeToggle script
function setTheme(theme) {
  // ... existing logic ...
  const announcement = document.getElementById('theme-announcement');
  if (announcement) {
    announcement.textContent = `${theme === 'dark' ? 'Dark' : 'Light'} mode activated`;
  }
}
```

### Reduced Motion

Ensure theme transitions respect `prefers-reduced-motion`:

```css
@media (prefers-reduced-motion: reduce) {
  html.theme-transitions,
  html.theme-transitions *,
  html.theme-transitions *::before,
  html.theme-transitions *::after {
    transition: none !important;
  }
}
```

---

## 9. Performance Considerations

### Critical Path Analysis

| Action | Blocking? | Impact |
|--------|-----------|--------|
| Inline theme script (FOUC prevention) | Yes — intentionally | < 0.1ms — negligible |
| `localStorage.getItem()` | Synchronous | < 0.05ms |
| `classList.add('dark')` | Synchronous | CSS recalc: ~1ms |
| ThemeToggle button render | No | Part of normal layout |
| CSS custom property swap | CSS engine | ~1-2ms per toggle |

**Total FOUC prevention cost: ~0.15ms** — well within acceptable range.

### Bundle Size

- `ThemeToggle.astro` script: ~800 bytes (minified ~400 bytes)
- CSS custom properties additions: ~3KB raw, ~1.2KB gzipped
- Tailwind semantic color additions: ~2KB additional utilities (tree-shaken)

**Total addition: ~2KB gzipped** — negligible.

### CSS Custom Properties vs. Class Duplication

Using CSS custom properties instead of duplicating every utility class in a `.dark` variant:

- **Without tokens:** ~200 dark: variant classes across all components = ~15KB extra CSS
- **With tokens:** 50 CSS custom properties, updated once on `<html>` = ~3KB

**Tokens approach is ~5x smaller.**

### Avoiding Layout Shift

The inline script runs before CSS parsing completes when placed at the very top of `<head>`. Since it only adds/removes a class (no DOM mutations that change dimensions), CLS is 0.

### Images in Dark Mode

No special handling needed — images don't change per theme. However, consider:
- SVG illustrations: use `currentColor` for theme-aware icon colors
- Avoid white-background PNG logos — use SVG or transparent PNG

For images that look better in dark mode (e.g., screenshots on white backgrounds), use the CSS filter approach sparingly:

```css
/* Only for specific screenshot images in dark mode */
.dark .screenshot-img {
  filter: invert(1) hue-rotate(180deg);
}
```

---

## 10. Implementation Phases

### Phase 1: Foundation (Required Before Any UI Work)
**Files to modify:**
1. `tailwind.config.mjs` — add `darkMode: 'class'`, semantic color tokens
2. `src/styles/global.css` — add CSS custom property tokens (`:root` + `:root.dark`)
3. `src/layouts/BaseLayout.astro` — add inline FOUC prevention script

**Estimated effort:** ~2 hours
**Risk:** Low — purely additive, zero breaking changes

---

### Phase 2: Toggle Component + Header Integration
**Files to create/modify:**
1. `src/components/ThemeToggle.astro` — new toggle button component
2. `src/components/Header.astro` — import and place ThemeToggle, migrate hardcoded colors

**Estimated effort:** ~3 hours
**Risk:** Low — isolated component changes

---

### Phase 3: Shared Components Migration
**Files to modify:**
1. `src/styles/global.css` — migrate `.card`, `.form-input`, `.badge-*`, `.btn-*` classes
2. `src/components/ui/Card.astro` — semantic tokens
3. `src/components/ui/Badge.astro` — semantic tokens
4. `src/components/ui/SectionHeader.astro` — semantic tokens
5. `src/components/Footer.astro` — dark/light surface tokens

**Estimated effort:** ~4 hours
**Risk:** Medium — affects all pages; thorough visual testing required

---

### Phase 4: Page-Level Migration
**Files to modify:**
1. `src/pages/index.astro` — light sections only (hero is already dark)
2. `src/pages/about.astro` — team grid, stats sections
3. `src/pages/services.astro` — service cards, feature lists
4. `src/pages/contact.astro` — form, FAQ sections
5. `src/pages/portfolio.astro` — filter bar, project cards
6. `src/pages/blog/index.astro` — post cards, category filters

**Estimated effort:** ~8 hours
**Risk:** Medium — large surface area; automated Playwright screenshot tests recommended

---

### Phase 5: Testing & QA
1. **Visual regression:** Playwright screenshots in both modes across all viewports
2. **Contrast audit:** Run automated contrast checks on all text/background pairs
3. **FOUC test:** Disable JS, reload — ensure graceful fallback (light mode)
4. **System preference:** Toggle OS dark mode — verify auto-detection works
5. **localStorage persistence:** Toggle, navigate, refresh — verify preference persists
6. **Keyboard test:** Tab to toggle, press Space/Enter — verify toggle works
7. **Screen reader test:** Verify aria-label/aria-pressed announcements

---

## Appendix A: Quick Reference Cheat Sheet

```
BACKGROUNDS:
  Page bg          → bg-surface-base
  Card/panel bg    → bg-surface-raised
  Hover/nav bg     → bg-surface-overlay
  Input bg         → bg-surface-sunken
  Dark section bg  → bg-surface-inverse (or keep explicit bg-secondary-900)

TEXT:
  Headings/body    → text-theme-text-primary
  Subtitles        → text-theme-text-secondary
  Captions/hints   → text-theme-text-muted
  On dark bg       → text-theme-text-inverse
  Links            → text-theme-text-link

BORDERS:
  Dividers/cards   → border-theme-border-subtle
  Inputs           → border-theme-border-DEFAULT
  Focused          → border-theme-border-interactive

BRAND:
  Primary actions  → bg-brand-primary (same both modes)
  Tinted badge bg  → bg-brand-primary-subtle
  Text on tinted   → text-brand-primary-text

DO NOT CHANGE (works in both modes already):
  - Hero/CTA dark gradient sections (bg-secondary-900, etc.)
  - Primary brand color (#0891b2) buttons
  - White text on primary/dark backgrounds
```

---

## Appendix B: File Change Summary

| File | Change Type | Description |
|------|------------|-------------|
| `tailwind.config.mjs` | Modify | Add `darkMode: 'class'`, semantic color tokens |
| `src/styles/global.css` | Modify | Add `:root` and `:root.dark` token blocks, migrate utility classes |
| `src/layouts/BaseLayout.astro` | Modify | Add FOUC prevention script, update body classes |
| `src/components/ThemeToggle.astro` | Create | New toggle button component |
| `src/components/Header.astro` | Modify | Import ThemeToggle, migrate hardcoded colors |
| `src/components/Footer.astro` | Modify | Migrate surface/text colors |
| `src/components/ui/*.astro` | Modify | Migrate to semantic tokens |
| `src/pages/*.astro` | Modify | Migrate light-mode sections |

---

*Document generated by frontend-specialist agent — 2026-03-21*
