# Theme System Guide **Project**: WorkRoot IT Solutions — Company Site **Date**: 2026-03-21 **Author**: documentation-writer agent **Status**: Production-ready --- ## Table of Contents 1. [Overview](#1-overview) 2. [How Theme Selection Works (User Guide)](#2-how-theme-selection-works-user-guide) 3. [Architecture & Implementation](#3-architecture--implementation) 4. [Design Tokens Reference](#4-design-tokens-reference) 5. [Developer Guide: Adding Theme Support to New Components](#5-developer-guide-adding-theme-support-to-new-components) 6. [Maintaining Theme Consistency](#6-maintaining-theme-consistency) 7. [Troubleshooting Guide](#7-troubleshooting-guide) 8. [Accessibility & WCAG Compliance](#8-accessibility--wcag-compliance) 9. [Performance Notes](#9-performance-notes) --- ## 1. Overview The site supports **dark and light themes** with: - Zero flash-of-unstyled-content (FOUC) — theme applies before first paint - Automatic detection of the OS/browser dark mode preference - Manual override persisted in `localStorage` - Automatic sync when the OS preference changes (if no manual override) - Full WCAG 2.1 AA/AAA contrast compliance in both themes - Screen reader announcements on theme change ### Files Involved | File | Role | |------|------| | `src/styles/design-tokens.css` | Single source of truth for all color tokens | | `src/components/ThemeToggle.astro` | Toggle button component (sun/moon icons) | | `src/layouts/BaseLayout.astro` | Inline blocking script that prevents FOUC | | `src/styles/global.css` | Component classes that consume tokens | | `tailwind.config.mjs` | `dark:` variant configuration | --- ## 2. How Theme Selection Works (User Guide) ### Automatic Detection When you first visit the site, the theme is set automatically based on your operating system or browser preference: - **macOS**: System Preferences → Appearance → Dark/Light - **Windows**: Settings → Personalization → Colors → Choose your color - **Android/iOS**: Display settings → Dark mode No action needed — the site matches your system setting out of the box. ### Manual Toggle A **sun/moon icon button** in the navigation header lets you manually switch themes at any time: - **Moon icon** (shown in light mode) → click to enable dark mode - **Sun icon** (shown in dark mode) → click to return to light mode Your choice is **saved in your browser** (via `localStorage`) and will persist across page navigations and browser restarts on the same device. ### Priority Order ``` 1. Manual user choice (localStorage) ← highest priority 2. OS/browser system preference 3. Light mode fallback ← default ``` ### Resetting to System Default To go back to automatic system-based theming, clear your browser's site data for this domain: - **Chrome**: DevTools → Application → Storage → Clear site data - **Firefox**: DevTools → Storage → Local Storage → delete the `theme` key - **Safari**: Develop → Website Data → remove site entry --- ## 3. Architecture & Implementation ### FOUC Prevention (Blocking Inline Script) The most critical piece — lives in `BaseLayout.astro` inside ``, runs synchronously before any CSS or HTML renders: ```html ``` **Why inline?** An external script would require a round-trip to the server, causing a visible flash. The inline IIFE runs synchronously as the browser parses the HTML. ### CSS Custom Properties (Semantic Tokens) Theme switching works entirely via CSS custom properties defined in `src/styles/design-tokens.css`. No JavaScript re-renders required. ```css /* Light mode (default) */ :root { --color-surface: #f8fafc; --color-text-primary: #1e293b; --color-primary: #0891b2; /* cyan */ } /* Dark mode override — applied when html.dark exists */ html.dark { --color-surface: #0f172a; --color-text-primary: #f1f5f9; --color-primary: #22d3ee; /* brighter cyan for dark bg */ } ``` Components use semantic tokens, never raw color values: ```css /* ✅ Correct — adapts to theme automatically */ .card { background: var(--color-surface-raised); } /* ❌ Wrong — hardcoded, breaks in dark mode */ .card { background: #ffffff; } ``` ### Tailwind Dark Variant Tailwind classes with the `dark:` prefix apply when `html.dark` is present: ```html

Content

...
``` The `dark:` variant is configured in `tailwind.config.mjs`: ```javascript // tailwind.config.mjs export default { darkMode: 'class', // uses html.dark class (not media query) // ... } ``` ### ThemeToggle Component `src/components/ThemeToggle.astro` implements the interactive button. Key behaviors: 1. **Multiple instances** — uses `data-theme-toggle` attribute + `querySelectorAll` to support both desktop and mobile nav toggles staying in sync 2. **ARIA switch role** — `role="switch"` with `aria-checked` updated on each toggle 3. **Live region** — `[data-theme-live]` announces "Dark mode enabled" / "Light mode enabled" to screen readers without interrupting reading flow 4. **System preference listener** — `matchMedia.addEventListener('change')` updates theme automatically when OS preference changes (only if no manual override stored) 5. **Theme-color meta** — updates `` so browser chrome (address bar) matches the theme ### Theme State Machine ``` Initial load │ ├─ localStorage === 'dark' → apply dark ├─ localStorage === 'light' → apply light ├─ no localStorage + system dark → apply dark └─ no localStorage + system light → apply light (default) User clicks toggle │ ├─ add/remove html.dark class ├─ save 'dark'/'light' to localStorage ├─ update aria-checked on all [data-theme-toggle] buttons ├─ update aria-label on all buttons ├─ announce to [data-theme-live] live regions └─ update all tags OS preference changes (no localStorage) │ ├─ toggle html.dark class ├─ update all [data-theme-toggle] aria-label/aria-checked └─ update ``` --- ## 4. Design Tokens Reference All tokens live in `src/styles/design-tokens.css`. The file follows a two-layer approach: 1. **Primitive palette** (`--palette-*`) — Raw color scale values. Not used directly in components. 2. **Semantic tokens** (`--color-*`) — Map primitives to intent. Use these in components. ### Semantic Token Groups #### Surfaces (backgrounds) | Token | Light | Dark | Use Case | |-------|-------|------|----------| | `--color-surface` | `#f8fafc` | `#0f172a` | Page background | | `--color-surface-alt` | `#f1f5f9` | `#1e293b` | Subtle variant | | `--color-surface-raised` | `#ffffff` | `#1e293b` | Cards, modals | | `--color-surface-inset` | `#f1f5f9` | `#0f172a` | Inputs, code blocks | | `--color-surface-sunken` | `#e2e8f0` | `#080f1a` | Deeply inset areas | #### Text | Token | Light | Dark | WCAG | |-------|-------|------|------| | `--color-text-primary` | `#1e293b` | `#f1f5f9` | AAA (14.7:1 / 14.3:1) | | `--color-text-secondary` | `#475569` | `#cbd5e1` | AAA (6.6:1 / 9.2:1) | | `--color-text-muted` | `#64748b` | `#94a3b8` | AA (4.6:1 / 5.4:1) | | `--color-text-disabled` | `#94a3b8` | `#475569` | Decorative only | | `--color-text-inverse` | `#ffffff` | `#0f172a` | Text on opposite surface | | `--color-text-link` | `#0e7490` | `#22d3ee` | Interactive links | #### Brand / Primary (Cyan) | Token | Light | Dark | |-------|-------|------| | `--color-primary` | `#0891b2` | `#22d3ee` | | `--color-primary-dark` | `#0e7490` | `#67e8f9` | | `--color-primary-light` | `#22d3ee` | `#67e8f9` | | `--color-primary-subtle` | `#ecfeff` | `rgba(8,145,178,0.12)` | #### Borders | Token | Light | Dark | |-------|-------|------| | `--color-border` | `#e2e8f0` | `#334155` | | `--color-border-strong` | `#cbd5e1` | `#475569` | | `--color-border-focus` | `#22d3ee` | `#22d3ee` | #### Status Colors All status tokens follow the same pattern for both themes: ```css --color-{status} /* base */ --color-{status}-text /* text on light/dark surface */ --color-{status}-subtle /* very light/transparent background */ --color-{status}-border /* border color */ ``` Statuses: `success`, `warning`, `error`, `info` ### Component Shorthand Tokens Convenience aliases that keep component classes concise: ```css --btn-primary-bg /* button background */ --btn-primary-text /* button label */ --btn-primary-hover /* button hover state */ --input-bg /* form input background */ --input-border /* form input border */ --card-bg /* card background */ --card-border /* card border */ --nav-bg /* header/nav background */ --nav-text /* nav link text */ ``` --- ## 5. Developer Guide: Adding Theme Support to New Components ### Step 1: Use Semantic Tokens, Not Raw Colors ```css /* ❌ Hardcoded — breaks in dark mode */ .my-component { background: #ffffff; color: #1e293b; border: 1px solid #e2e8f0; } /* ✅ Semantic tokens — adapts automatically */ .my-component { background: var(--color-surface-raised); color: var(--color-text-primary); border: 1px solid var(--color-border); } ``` ### Step 2: Use Tailwind Dark Variants for Utility Classes ```html
``` ### Step 3: Test Icon and SVG Colors Icons using `currentColor` inherit from their parent's `color` property — they adapt automatically. Hardcoded `fill` or `stroke` values need dark variants: ```html ``` ### Step 4: Handle Gradients and Decorative Backgrounds Hero sections use dark backgrounds that look fine in both themes. For sections that change between light/dark: ```html
``` ### Step 5: Forms — Use Token-Based Classes Form elements already have full dark-mode support via the `form-input` and `form-label` classes defined in `global.css`: ```html
Required ``` If you write a custom form element, mirror the token usage from `global.css`. ### Step 6: New Astro Component Template ```astro --- // src/components/MyComponent.astro interface Props { title: string; variant?: 'default' | 'accent'; } const { title, variant = 'default' } = Astro.props; ---

{title}

``` ### Step 7: Verify Contrast Before shipping a new component, verify text contrast in both themes: **Tools:** - [WebAIM Contrast Checker](https://webaim.org/resources/contrastchecker/) - Chrome DevTools → Elements → Computed → contrast ratio badge - Lighthouse accessibility audit **Minimum requirements:** - Body text: **4.5:1** (AA) - Large text (18px+ / 14px+ bold): **3:1** (AA) - Target: **7:1** (AAA) for critical text All semantic text tokens in this design system already meet AA or AAA — use them and you're covered. --- ## 6. Maintaining Theme Consistency ### The Golden Rules 1. **Never use raw hex colors in components** — always use `var(--color-*)` or Tailwind semantic classes 2. **Always add `dark:` variants** when using Tailwind utility classes for colors 3. **Test in both themes** before committing new UI 4. **Use the existing token names** — don't create new tokens unless there's a genuine gap ### Checking for Theme Consistency Issues Quick visual review process: 1. Open the site in a browser 2. Toggle to dark mode using the header button 3. Navigate all pages looking for: - White text on white background (contrast failure) - Very dark text on dark background (contrast failure) - Colorful decorative elements that disappeared (hidden in dark) - Form inputs that look broken 4. Repeat with `prefers-reduced-motion` enabled in DevTools ### Adding New Colors to the Token System 1. Add the primitive to `src/styles/design-tokens.css` in the `:root` block: ```css --palette-brand-new-500: #ff6b6b; ``` 2. Add the semantic token for both light and dark modes: ```css :root { --color-brand-new: var(--palette-brand-new-500); } html.dark { --color-brand-new: var(--palette-brand-new-400); /* adjust for dark bg */ } ``` 3. Extend `tailwind.config.mjs` if you need Tailwind utility class support: ```javascript colors: { 'brand-new': { 400: '#ff8e8e', 500: '#ff6b6b', } } ``` 4. Verify contrast in both themes before using. ### Don't Repeat Design Token Values If you find yourself writing the same color value in multiple places, extract it to a token. Tokens are the single source of truth — changing a token value updates every component that uses it. --- ## 7. Troubleshooting Guide ### Issue: Flash of wrong theme on page load (FOUC) **Symptoms**: Page briefly shows light mode before switching to dark (or vice versa). **Cause**: The inline blocking script in `BaseLayout.astro` is missing, disabled, or placed after CSS. **Fix**: Ensure the theme detection script is the **first** ` ``` --- ### Issue: Theme toggle button doesn't respond **Symptoms**: Clicking the sun/moon icon has no effect. **Diagnosis steps**: 1. Open browser DevTools → Console. Look for JavaScript errors. 2. Check that `[data-theme-toggle]` attribute exists on the button element. 3. Confirm `initThemeToggles()` ran — add `console.log` after the function call. **Common causes**: - The button HTML was modified and `data-theme-toggle` attribute was removed - A JavaScript error elsewhere in `