Dark Mode Implementation Plan

Overview

This document outlines the plan to implement dark mode for Sketch's web UI using Tailwind CSS's built-in dark mode capabilities.

Current State Analysis

Sketch's web UI currently uses:

  • Tailwind CSS with basic configuration
  • Lit web components extending SketchTailwindElement
  • Components avoid Shadow DOM (createRenderRoot() { return this; })
  • Standard Tailwind classes like bg-white, text-gray-600, border-gray-200
  • Global CSS approach, making dark mode implementation straightforward

Implementation Strategy: Class-Based Dark Mode

Phase 1: Foundation

1. Update Tailwind Configuration

// tailwind.config.js
export default {
  content: ["./src/**/*.{js,ts,jsx,tsx,html}"],
  darkMode: "class", // Enable class-based dark mode
  plugins: ["@tailwindcss/container-queries"],
  theme: {
    extend: {
      // Custom colors for better dark mode support
      colors: {
        // Define semantic color tokens
        surface: {
          DEFAULT: "#ffffff",
          dark: "#1f2937",
        },
        "surface-secondary": {
          DEFAULT: "#f9fafb",
          dark: "#374151",
        },
      },
      animation: {
        "fade-in": "fadeIn 0.3s ease-in-out",
      },
      keyframes: {
        fadeIn: {
          "0%": {
            opacity: "0",
            transform: "translateX(-50%) translateY(10px)",
          },
          "100%": {
            opacity: "1",
            transform: "translateX(-50%) translateY(0)",
          },
        },
      },
    },
  },
};

2. Create Theme Management Service

// src/web-components/theme-service.ts
export class ThemeService {
  private static instance: ThemeService;

  static getInstance(): ThemeService {
    if (!this.instance) {
      this.instance = new ThemeService();
    }
    return this.instance;
  }

  toggleTheme(): void {
    const isDark = document.documentElement.classList.contains("dark");
    this.setTheme(isDark ? "light" : "dark");
  }

  setTheme(theme: "light" | "dark"): void {
    document.documentElement.classList.toggle("dark", theme === "dark");
    localStorage.setItem("theme", theme);

    // Dispatch event for components that need to react
    document.dispatchEvent(
      new CustomEvent("theme-changed", {
        detail: { theme },
      }),
    );
  }

  getTheme(): "light" | "dark" {
    return document.documentElement.classList.contains("dark")
      ? "dark"
      : "light";
  }

  initializeTheme(): void {
    const saved = localStorage.getItem("theme");
    const prefersDark = window.matchMedia(
      "(prefers-color-scheme: dark)",
    ).matches;
    const theme = saved || (prefersDark ? "dark" : "light");
    this.setTheme(theme as "light" | "dark");

    // Listen for system theme changes
    window
      .matchMedia("(prefers-color-scheme: dark)")
      .addEventListener("change", (e) => {
        if (!localStorage.getItem("theme")) {
          this.setTheme(e.matches ? "dark" : "light");
        }
      });
  }
}

3. Theme Toggle Component

// src/web-components/theme-toggle.ts
import { html } from "lit";
import { customElement, state } from "lit/decorators.js";
import { SketchTailwindElement } from "./sketch-tailwind-element.js";
import { ThemeService } from "./theme-service.js";

@customElement("theme-toggle")
export class ThemeToggle extends SketchTailwindElement {
  @state() private isDark = false;

  private themeService = ThemeService.getInstance();

  connectedCallback() {
    super.connectedCallback();
    this.isDark = document.documentElement.classList.contains("dark");

    // Listen for theme changes from other sources
    document.addEventListener("theme-changed", this.handleThemeChange);
  }

  disconnectedCallback() {
    super.disconnectedCallback();
    document.removeEventListener("theme-changed", this.handleThemeChange);
  }

  private handleThemeChange = (e: CustomEvent) => {
    this.isDark = e.detail.theme === "dark";
  };

  private toggleTheme() {
    this.themeService.toggleTheme();
  }

  render() {
    return html`
      <button
        @click=${this.toggleTheme}
        class="p-2 rounded-md border border-gray-300 dark:border-gray-600 
               bg-white dark:bg-gray-800 text-gray-700 dark:text-gray-200
               hover:bg-gray-50 dark:hover:bg-gray-700 transition-colors
               focus:outline-none focus:ring-2 focus:ring-blue-500"
        title="Toggle theme"
        aria-label="Toggle between light and dark mode"
      >
        ${this.isDark ? "☀️" : "🌙"}
      </button>
    `;
  }
}

declare global {
  interface HTMLElementTagNameMap {
    "theme-toggle": ThemeToggle;
  }
}

4. Initialize Theme in App Shell

Add theme initialization to the main app shell component:

// In sketch-app-shell.ts or similar
import { ThemeService } from "./theme-service.js";

connectedCallback() {
  super.connectedCallback();
  ThemeService.getInstance().initializeTheme();
}

Phase 2: Component Updates

Systematic Component Audit

  1. Identify all components using color classes

    • Search for bg-, text-, border-, ring-, divide- classes
    • Document current color usage patterns
  2. Add dark mode variants systematically

    • Start with core components (app shell, chat input, etc.)

    • Update classes following the pattern:

      // Before:
      class="bg-white text-gray-900 border-gray-200"
      
      // After:
      class="bg-white dark:bg-gray-900 text-gray-900 dark:text-gray-100 border-gray-200 dark:border-gray-700"
      
  3. Priority order for component updates:

    1. sketch-app-shell - Main container
    2. sketch-chat-input - Primary interaction component
    3. sketch-container-status - Status indicators
    4. sketch-call-status - Call indicators
    5. Demo components and other secondary components

Common Dark Mode Color Mappings

// Light -> Dark mappings
bg-white -> bg-gray-900
bg-gray-50 -> bg-gray-800
bg-gray-100 -> bg-gray-800
bg-gray-200 -> bg-gray-700

text-gray-900 -> text-gray-100
text-gray-800 -> text-gray-200
text-gray-700 -> text-gray-300
text-gray-600 -> text-gray-400
text-gray-500 -> text-gray-500 (neutral)

border-gray-200 -> border-gray-700
border-gray-300 -> border-gray-600

ring-gray-300 -> ring-gray-600

Phase 3: Polish and Testing

1. Smooth Transitions

  • Add transition-colors to interactive elements
  • Consider adding a global transition class for theme changes

2. Accessibility

  • Ensure sufficient contrast ratios in both modes
  • Test with screen readers
  • Verify focus indicators work in both themes
  • Add proper ARIA labels to theme toggle

3. Testing Checklist

  • [ ] Theme persists across page reloads
  • [ ] System preference detection works
  • [ ] All components render correctly in both modes
  • [ ] Interactive states (hover, focus, active) work in both themes
  • [ ] No flash of unstyled content (FOUC)
  • [ ] Works across different screen sizes
  • [ ] Performance impact is minimal

File Structure

src/web-components/
├── theme-service.ts          # Theme management service
├── theme-toggle.ts           # Theme toggle component
├── sketch-tailwind-element.ts # Base class (existing)
└── [other components].ts     # Updated with dark mode variants

Benefits of This Approach

  • Incremental: Can be implemented component by component
  • Standard: Uses Tailwind's built-in dark mode features
  • Performant: Class-based approach is efficient
  • Maintainable: Clear separation of concerns with theme service
  • Accessible: Respects system preferences by default
  • Consistent: Follows Sketch's existing component patterns

Implementation Timeline

  1. Week 1: Phase 1 - Foundation (config, service, toggle)
  2. Week 2: Phase 2 - Core component updates
  3. Week 3: Phase 2 - Secondary component updates
  4. Week 4: Phase 3 - Polish, testing, and accessibility

Notes

  • Components extend SketchTailwindElement (not LitElement)
  • No Shadow DOM usage allows for global Tailwind classes
  • Theme service uses singleton pattern for consistency
  • Event system allows components to react to theme changes
  • LocalStorage preserves user preference across sessions