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
- Overview & Strategy
- Color Token Design
- CSS Custom Properties Structure
- Tailwind Configuration
- FOUC Prevention (No Flash)
- Theme Switching Logic
- Component Migration Guide
- Accessibility & WCAG Compliance
- Performance Considerations
- Implementation Phases
1. Overview & Strategy
Approach: CSS Custom Properties + Tailwind darkMode: 'class'
The chosen strategy combines two mechanisms:
- CSS Custom Properties (semantic tokens) — define abstract color names like
--color-surfacethat change value per theme. All components reference tokens, never raw colors. - Tailwind
darkMode: 'class'— thedarkclass 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-carddark 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-800continue to work unchanged. The new semantic tokens are additive — migrate components incrementally usingbg-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:
- Page renders in light mode (default CSS)
- JS reads localStorage, applies
darkclass - 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:
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)
<!-- 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-mutedin light mode: At 3.0:1 ratio, it only passes WCAG AA for large text (18pt+ or 14pt bold). Use--text-mutedonly 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-pressedreflecting current state - Uses
focus-visible:ring-2for 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.astroscript: ~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
currentColorfor 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:
tailwind.config.mjs— adddarkMode: 'class', semantic color tokenssrc/styles/global.css— add CSS custom property tokens (:root+:root.dark)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:
src/components/ThemeToggle.astro— new toggle button componentsrc/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:
src/styles/global.css— migrate.card,.form-input,.badge-*,.btn-*classessrc/components/ui/Card.astro— semantic tokenssrc/components/ui/Badge.astro— semantic tokenssrc/components/ui/SectionHeader.astro— semantic tokenssrc/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:
src/pages/index.astro— light sections only (hero is already dark)src/pages/about.astro— team grid, stats sectionssrc/pages/services.astro— service cards, feature listssrc/pages/contact.astro— form, FAQ sectionssrc/pages/portfolio.astro— filter bar, project cardssrc/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
- Visual regression: Playwright screenshots in both modes across all viewports
- Contrast audit: Run automated contrast checks on all text/background pairs
- FOUC test: Disable JS, reload — ensure graceful fallback (light mode)
- System preference: Toggle OS dark mode — verify auto-detection works
- localStorage persistence: Toggle, navigate, refresh — verify preference persists
- Keyboard test: Tab to toggle, press Space/Enter — verify toggle works
- 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