Files
CompanySite/.agents/documentation-writer/THEME_SYSTEM_GUIDE.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

21 KiB

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
  2. How Theme Selection Works (User Guide)
  3. Architecture & Implementation
  4. Design Tokens Reference
  5. Developer Guide: Adding Theme Support to New Components
  6. Maintaining Theme Consistency
  7. Troubleshooting Guide
  8. Accessibility & WCAG Compliance
  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 <head>, runs synchronously before any CSS or HTML renders:

<head>
  <script>
    // Runs BEFORE page paint — prevents flash of wrong theme
    (function() {
      try {
        const stored = localStorage.getItem('theme');
        if (stored === 'dark' || (!stored && window.matchMedia('(prefers-color-scheme: dark)').matches)) {
          document.documentElement.classList.add('dark');
        }
      } catch(e) {}
    })();
  </script>
  <!-- CSS loads here, already knowing which theme class is on <html> -->
</head>

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.

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

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

<!-- Text is dark on light bg, light on dark bg -->
<p class="text-secondary-700 dark:text-secondary-300">Content</p>

<!-- Card adapts its background -->
<div class="bg-white dark:bg-secondary-800 rounded-lg p-6">...</div>

The dark: variant is configured in tailwind.config.mjs:

// 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 rolerole="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 listenermatchMedia.addEventListener('change') updates theme automatically when OS preference changes (only if no manual override stored)
  5. Theme-color meta — updates <meta name="theme-color"> 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 <meta name="theme-color"> tags

OS preference changes (no localStorage)
  │
  ├─ toggle html.dark class
  ├─ update all [data-theme-toggle] aria-label/aria-checked
  └─ update <meta name="theme-color">

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:

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

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

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

<!-- ❌ Light-only -->
<div class="bg-white text-slate-700 border-slate-200">

<!-- ✅ Both themes -->
<div class="bg-white dark:bg-secondary-800 text-secondary-700 dark:text-secondary-300 border-secondary-200 dark:border-secondary-700">

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:

<!-- ✅ Inherits theme color via currentColor -->
<svg class="text-primary dark:text-primary-400" fill="none" stroke="currentColor">

<!-- ❌ Hardcoded fill — won't adapt -->
<svg fill="#0891b2">

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:

<!-- Section that adapts -->
<section class="bg-secondary-50 dark:bg-secondary-900 py-24">

<!-- Hero — dark in both themes by design (uses its own gradient) -->
<section class="bg-hero-dark py-32">

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:

<!-- These automatically adapt to both themes -->
<label class="form-label" for="name">Name</label>
<input class="form-input" id="name" type="text" />
<span class="form-error">Required</span>

If you write a custom form element, mirror the token usage from global.css.

Step 6: New Astro Component Template

---
// src/components/MyComponent.astro
interface Props {
  title: string;
  variant?: 'default' | 'accent';
}
const { title, variant = 'default' } = Astro.props;
---

<div class:list={[
  'rounded-lg p-6 border transition-colors duration-200',
  'bg-white dark:bg-secondary-800',
  'border-secondary-200 dark:border-secondary-700',
  'text-secondary-700 dark:text-secondary-300',
  variant === 'accent' && 'border-l-4 border-l-primary dark:border-l-primary-400'
]}>
  <h3 class="font-semibold text-secondary-800 dark:text-secondary-100">
    {title}
  </h3>
  <slot />
</div>

<style>
  /* Use CSS custom properties for values not expressible in Tailwind */
  div {
    box-shadow: var(--card-shadow);
  }
</style>

Step 7: Verify Contrast

Before shipping a new component, verify text contrast in both themes:

Tools:

  • WebAIM Contrast Checker
  • 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:

    --palette-brand-new-500: #ff6b6b;
    
  2. Add the semantic token for both light and dark modes:

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

    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 <script> tag in <head>, before any <link> stylesheet tags. It must be synchronous (no defer, no async).

<head>
  <!-- ✅ Script BEFORE stylesheets -->
  <script>/* theme detection */</script>
  <link rel="stylesheet" href="..." />
</head>

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 <script> blocks prevented the file from executing
  • The component was added to a page that blocks inline scripts via CSP

Issue: Theme preference not persisting across page loads

Symptoms: Theme resets to system default on every page load.

Cause: localStorage is unavailable (private browsing) or the key is being cleared.

Fix: This is expected behavior in private/incognito mode. In regular browsing, check:

// In browser console:
localStorage.getItem('theme'); // should return 'dark' or 'light'

If it returns null, the write is failing silently. Check the try/catch around localStorage.setItem in ThemeToggle.astro.


Issue: Dark mode colors look washed out or have poor contrast

Symptoms: Text is hard to read in dark mode.

Cause: Component is using hardcoded light-mode colors or Tailwind classes without dark: variants.

Diagnosis:

  1. Inspect the element in DevTools
  2. Look for color, background-color, or border-color values that are light (near #fff) without dark: variants
  3. Search the component file for raw hex values like #ffffff or white

Fix: Replace hardcoded values with semantic tokens or add dark: variants.


Issue: Component looks correct in light mode but invisible in dark mode

Symptoms: A card, badge, or section disappears or merges into the background in dark mode.

Common cause: Background and text colors both resolving to dark values.

Example:

<!-- Both bg and text are dark — text invisible in dark mode -->
<span class="bg-secondary-100 text-secondary-700">
  <!-- dark:bg-secondary-100 is still nearly white, but there's no dark:text override -->
</span>

Fix: Ensure both background and text have explicit dark: variants:

<span class="bg-secondary-100 dark:bg-secondary-800 text-secondary-700 dark:text-secondary-200">

Issue: Icon colors not adapting to theme

Symptoms: SVG icons remain the same color in both themes.

Cause: Icon has a hardcoded fill or stroke attribute.

Fix: Use currentColor and set color via Tailwind:

<svg class="text-primary-600 dark:text-primary-400" fill="currentColor">
  <path .../>
</svg>

Issue: System preference change not detected

Symptoms: Toggling OS dark mode doesn't update the site (only when no manual override).

Cause: The matchMedia change listener in ThemeToggle.astro is not attached.

Check: In browser console:

window.matchMedia('(prefers-color-scheme: dark)').matches
// toggle OS theme and check again

If the value changes but the site doesn't update, verify the event listener in ThemeToggle.astro is being registered (it may have errored before reaching that line).


Issue: Screen reader doesn't announce theme changes

Symptoms: Keyboard users toggle the theme but hear no announcement.

Cause: The [data-theme-live] <span role="status" aria-live="polite"> element is missing from the DOM, or its text content isn't being updated.

Fix: Ensure ThemeToggle.astro is rendering the live region element, and that announceThemeChange() is calling querySelectorAll('[data-theme-live]') (not querySelector).


Issue: Browser chrome (address bar) color doesn't match theme

Symptoms: The browser's native UI (address bar, tab bar on mobile) stays one color regardless of theme.

Cause: The <meta name="theme-color"> tag isn't being updated by applyTheme().

Check: In browser console after toggling:

document.querySelectorAll('meta[name="theme-color"]');
// Check the content attribute value

The applyTheme() function should update all meta[name="theme-color"] elements to #0f172a (dark) or #0891b2 (light).


8. Accessibility & WCAG Compliance

Contrast Ratios

All text tokens meet WCAG 2.1 AA at minimum. Most meet AAA.

Token Light Ratio Dark Ratio Level
--color-text-primary 14.7:1 14.3:1 AAA
--color-text-secondary 6.6:1 9.2:1 AAA
--color-text-muted 4.6:1 5.4:1 AA
Primary brand color 4.5:1 9.1:1 AA/AAA
Accent (amber) 3.0:1 11.5:1 AA/AAA

ARIA Implementation

The theme toggle uses role="switch" (semantically correct for on/off controls) with:

  • aria-checked="false" in light mode, "true" in dark mode
  • aria-label updated on each toggle ("Switch to dark mode" / "Switch to light mode")
  • aria-hidden="true" on decorative SVG icons
  • sr-only live region for screen reader announcements
  • Visible focus ring (focus:ring-2) for keyboard navigation

Reduced Motion

All theme transition animations respect prefers-reduced-motion: reduce:

@media (prefers-reduced-motion: reduce) {
  .theme-toggle .sun-icon,
  .theme-toggle .moon-icon {
    transition: none !important;
  }
}

The icon swap still occurs — only the CSS transition animation is suppressed.


9. Performance Notes

Theme Initialization Cost

The blocking inline script adds ~0.1ms to initial HTML parse time. This is negligible compared to a FOUC (visible flash), which would negatively impact Cumulative Layout Shift (CLS) and user experience.

CSS Custom Properties

CSS custom properties are resolved at paint time by the browser. Having two sets of tokens (:root and html.dark) doesn't duplicate CSS rules — the same property names are used, just with different values. This is a standard, well-optimized browser pattern.

No JavaScript Re-renders

Theme switching updates a single class on <html>, which causes a CSS repaint. There is no JavaScript component tree re-render. The entire site can theme-switch in a single frame.

localStorage Access

The localStorage.getItem('theme') call in the blocking script is synchronous and fast (~0.01ms). It's wrapped in a try/catch to handle private browsing mode without errors.

Bundle Impact

The theme system adds:

  • design-tokens.css: ~8KB (unminified), ~3KB gzipped
  • ThemeToggle.astro inline script: ~1.5KB
  • BaseLayout blocking script: ~200 bytes

Total theme system overhead: ~5KB gzipped — minimal.


Generated by documentation-writer agent on 2026-03-21. Update this guide when making changes to the theme system.