Files
CompanySite/.agents/frontend-specialist/THEME_SYSTEM_ARCHITECTURE.md
T
Clintchiz 0614ae6f85
E2E Test Suite / Critical User Journeys (push) Has been skipped
E2E Test Suite / API Integration Tests (push) Has been skipped
Deploy to Production / Build & Verify (push) Failing after 13s
Ping Search Engines / Notify Search Engines (push) Successful in 3s
Deploy to Production / Pre-Deploy Tests (push) Has been skipped
Deploy to Production / Deploy to Railway (push) Has been skipped
Deploy to Production / Deploy to Render (push) Has been skipped
Deploy to Production / Deploy to VPS (PM2) (push) Has been skipped
Deploy to Production / Deploy to Fly.io (push) Has been skipped
Deploy to Production / Post-Deploy Verification (push) Has been skipped
Deploy to Production / Notify on Failure (push) Successful in 1s
E2E Test Suite / Smoke Tests (P0) (push) Failing after 9m36s
E2E Test Suite / Form Interaction Tests (push) Failing after 12m6s
E2E Test Suite / Destructive & Chaos Tests (push) Failing after 11m46s
E2E Test Suite / Cross-Browser Regression (chromium) (push) Failing after 9m31s
E2E Test Suite / Cross-Browser Regression (firefox) (push) Failing after 11m5s
E2E Test Suite / Cross-Browser Regression (webkit) (push) Failing after 15m24s
E2E Test Suite / Security Header Tests (push) Failing after 7m55s
E2E Test Suite / Test Report Summary (push) Failing after 6s
E2E Test Suite / Mobile Device Tests (push) Failing after 3h12m28s
Uptime Monitor / Health & Response Time (push) Successful in 5s
Uptime Monitor / SSL Certificate (push) Successful in 3s
Uptime Monitor / Send Alerts (push) Has been skipped
Uptime Monitor / Record Uptime Success (push) Successful in 2s
Latest Updated Pages
2026-03-22 14:37:17 +05:30

28 KiB

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
  2. Color Token Design
  3. CSS Custom Properties Structure
  4. Tailwind Configuration
  5. FOUC Prevention (No Flash)
  6. Theme Switching Logic
  7. Component Migration Guide
  8. Accessibility & WCAG Compliance
  9. Performance Considerations
  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

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

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:

<!-- 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:

<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

---
// 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:

---
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):

/* 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;
}
// 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:

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)

<!-- 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)

<!-- 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:

/* 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;
}
/* 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;
}
/* 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:

<!-- In BaseLayout.astro, near end of <body> -->
<div
  id="theme-announcement"
  role="status"
  aria-live="polite"
  aria-atomic="true"
  class="sr-only"
></div>
// 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:

@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:

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