| banksean | ae3724e | 2025-07-18 16:52:37 +0000 | [diff] [blame] | 1 | # Dark Mode Implementation Plan |
| 2 | |
| 3 | ## Overview |
| 4 | |
| 5 | This document outlines the plan to implement dark mode for Sketch's web UI using Tailwind CSS's built-in dark mode capabilities. |
| 6 | |
| 7 | ## Current State Analysis |
| 8 | |
| 9 | Sketch's web UI currently uses: |
| 10 | |
| 11 | - Tailwind CSS with basic configuration |
| 12 | - Lit web components extending `SketchTailwindElement` |
| 13 | - Components avoid Shadow DOM (`createRenderRoot() { return this; }`) |
| 14 | - Standard Tailwind classes like `bg-white`, `text-gray-600`, `border-gray-200` |
| 15 | - Global CSS approach, making dark mode implementation straightforward |
| 16 | |
| 17 | ## Implementation Strategy: Class-Based Dark Mode |
| 18 | |
| 19 | ### Phase 1: Foundation |
| 20 | |
| 21 | #### 1. Update Tailwind Configuration |
| 22 | |
| 23 | ```javascript |
| 24 | // tailwind.config.js |
| 25 | export default { |
| banksean | 3eaa433 | 2025-07-19 02:19:06 +0000 | [diff] [blame^] | 26 | content: ["./src/**/*.{js,ts,jsx,tsx,html}", "./src/test-theme.html"], |
| 27 | darkMode: "selector", // Enable selector-based dark mode |
| banksean | ae3724e | 2025-07-18 16:52:37 +0000 | [diff] [blame] | 28 | plugins: ["@tailwindcss/container-queries"], |
| 29 | theme: { |
| 30 | extend: { |
| 31 | // Custom colors for better dark mode support |
| 32 | colors: { |
| 33 | // Define semantic color tokens |
| 34 | surface: { |
| 35 | DEFAULT: "#ffffff", |
| 36 | dark: "#1f2937", |
| 37 | }, |
| 38 | "surface-secondary": { |
| 39 | DEFAULT: "#f9fafb", |
| 40 | dark: "#374151", |
| 41 | }, |
| 42 | }, |
| 43 | animation: { |
| 44 | "fade-in": "fadeIn 0.3s ease-in-out", |
| 45 | }, |
| 46 | keyframes: { |
| 47 | fadeIn: { |
| 48 | "0%": { |
| 49 | opacity: "0", |
| 50 | transform: "translateX(-50%) translateY(10px)", |
| 51 | }, |
| 52 | "100%": { |
| 53 | opacity: "1", |
| 54 | transform: "translateX(-50%) translateY(0)", |
| 55 | }, |
| 56 | }, |
| 57 | }, |
| 58 | }, |
| 59 | }, |
| 60 | }; |
| 61 | ``` |
| 62 | |
| banksean | 3eaa433 | 2025-07-19 02:19:06 +0000 | [diff] [blame^] | 63 | #### 2. Theme Management Service (Already Implemented) |
| banksean | ae3724e | 2025-07-18 16:52:37 +0000 | [diff] [blame] | 64 | |
| 65 | ```typescript |
| 66 | // src/web-components/theme-service.ts |
| banksean | 3eaa433 | 2025-07-19 02:19:06 +0000 | [diff] [blame^] | 67 | export type ThemeMode = "light" | "dark" | "system"; |
| 68 | |
| banksean | ae3724e | 2025-07-18 16:52:37 +0000 | [diff] [blame] | 69 | export class ThemeService { |
| 70 | private static instance: ThemeService; |
| banksean | 3eaa433 | 2025-07-19 02:19:06 +0000 | [diff] [blame^] | 71 | private systemPrefersDark = false; |
| 72 | private systemMediaQuery: MediaQueryList; |
| banksean | ae3724e | 2025-07-18 16:52:37 +0000 | [diff] [blame] | 73 | |
| 74 | static getInstance(): ThemeService { |
| 75 | if (!this.instance) { |
| 76 | this.instance = new ThemeService(); |
| 77 | } |
| 78 | return this.instance; |
| 79 | } |
| 80 | |
| banksean | 3eaa433 | 2025-07-19 02:19:06 +0000 | [diff] [blame^] | 81 | /** |
| 82 | * Cycle through theme modes: light -> dark -> system -> light |
| 83 | */ |
| banksean | ae3724e | 2025-07-18 16:52:37 +0000 | [diff] [blame] | 84 | toggleTheme(): void { |
| banksean | 3eaa433 | 2025-07-19 02:19:06 +0000 | [diff] [blame^] | 85 | const currentTheme = this.getTheme(); |
| 86 | let nextTheme: ThemeMode; |
| 87 | |
| 88 | switch (currentTheme) { |
| 89 | case "light": |
| 90 | nextTheme = "dark"; |
| 91 | break; |
| 92 | case "dark": |
| 93 | nextTheme = "system"; |
| 94 | break; |
| 95 | case "system": |
| 96 | nextTheme = "light"; |
| 97 | break; |
| 98 | default: |
| 99 | nextTheme = "light"; |
| 100 | } |
| 101 | |
| 102 | this.setTheme(nextTheme); |
| banksean | ae3724e | 2025-07-18 16:52:37 +0000 | [diff] [blame] | 103 | } |
| 104 | |
| banksean | 3eaa433 | 2025-07-19 02:19:06 +0000 | [diff] [blame^] | 105 | setTheme(theme: ThemeMode): void { |
| 106 | // Store the theme preference |
| 107 | if (theme === "system") { |
| 108 | localStorage.removeItem("theme"); |
| 109 | } else { |
| 110 | localStorage.setItem("theme", theme); |
| 111 | } |
| 112 | |
| 113 | // Apply the theme |
| 114 | this.applyTheme(); |
| banksean | ae3724e | 2025-07-18 16:52:37 +0000 | [diff] [blame] | 115 | |
| 116 | // Dispatch event for components that need to react |
| 117 | document.dispatchEvent( |
| 118 | new CustomEvent("theme-changed", { |
| banksean | 3eaa433 | 2025-07-19 02:19:06 +0000 | [diff] [blame^] | 119 | detail: { |
| 120 | theme, |
| 121 | effectiveTheme: this.getEffectiveTheme(), |
| 122 | systemPrefersDark: this.systemPrefersDark, |
| 123 | }, |
| banksean | ae3724e | 2025-07-18 16:52:37 +0000 | [diff] [blame] | 124 | }), |
| 125 | ); |
| 126 | } |
| 127 | |
| banksean | 3eaa433 | 2025-07-19 02:19:06 +0000 | [diff] [blame^] | 128 | getTheme(): ThemeMode { |
| 129 | const saved = localStorage.getItem("theme"); |
| 130 | if (saved === "light" || saved === "dark") { |
| 131 | return saved; |
| 132 | } |
| 133 | return "system"; |
| 134 | } |
| 135 | |
| 136 | getEffectiveTheme(): "light" | "dark" { |
| 137 | const theme = this.getTheme(); |
| 138 | if (theme === "system") { |
| 139 | return this.systemPrefersDark ? "dark" : "light"; |
| 140 | } |
| 141 | return theme; |
| banksean | ae3724e | 2025-07-18 16:52:37 +0000 | [diff] [blame] | 142 | } |
| 143 | |
| 144 | initializeTheme(): void { |
| banksean | 3eaa433 | 2025-07-19 02:19:06 +0000 | [diff] [blame^] | 145 | this.applyTheme(); |
| banksean | ae3724e | 2025-07-18 16:52:37 +0000 | [diff] [blame] | 146 | } |
| 147 | } |
| 148 | ``` |
| 149 | |
| banksean | 3eaa433 | 2025-07-19 02:19:06 +0000 | [diff] [blame^] | 150 | #### 3. Theme Toggle Component (Already Implemented) |
| banksean | ae3724e | 2025-07-18 16:52:37 +0000 | [diff] [blame] | 151 | |
| 152 | ```typescript |
| banksean | 3eaa433 | 2025-07-19 02:19:06 +0000 | [diff] [blame^] | 153 | // src/web-components/sketch-theme-toggle.ts |
| banksean | ae3724e | 2025-07-18 16:52:37 +0000 | [diff] [blame] | 154 | import { html } from "lit"; |
| 155 | import { customElement, state } from "lit/decorators.js"; |
| 156 | import { SketchTailwindElement } from "./sketch-tailwind-element.js"; |
| banksean | 3eaa433 | 2025-07-19 02:19:06 +0000 | [diff] [blame^] | 157 | import { ThemeService, ThemeMode } from "./theme-service.js"; |
| banksean | ae3724e | 2025-07-18 16:52:37 +0000 | [diff] [blame] | 158 | |
| banksean | 3eaa433 | 2025-07-19 02:19:06 +0000 | [diff] [blame^] | 159 | @customElement("sketch-theme-toggle") |
| 160 | export class SketchThemeToggle extends SketchTailwindElement { |
| 161 | @state() private currentTheme: ThemeMode = "system"; |
| 162 | @state() private effectiveTheme: "light" | "dark" = "light"; |
| banksean | ae3724e | 2025-07-18 16:52:37 +0000 | [diff] [blame] | 163 | |
| 164 | private themeService = ThemeService.getInstance(); |
| 165 | |
| 166 | connectedCallback() { |
| 167 | super.connectedCallback(); |
| banksean | 3eaa433 | 2025-07-19 02:19:06 +0000 | [diff] [blame^] | 168 | this.updateThemeState(); |
| banksean | ae3724e | 2025-07-18 16:52:37 +0000 | [diff] [blame] | 169 | |
| 170 | // Listen for theme changes from other sources |
| 171 | document.addEventListener("theme-changed", this.handleThemeChange); |
| 172 | } |
| 173 | |
| 174 | disconnectedCallback() { |
| 175 | super.disconnectedCallback(); |
| 176 | document.removeEventListener("theme-changed", this.handleThemeChange); |
| 177 | } |
| 178 | |
| 179 | private handleThemeChange = (e: CustomEvent) => { |
| banksean | 3eaa433 | 2025-07-19 02:19:06 +0000 | [diff] [blame^] | 180 | this.currentTheme = e.detail.theme; |
| 181 | this.effectiveTheme = e.detail.effectiveTheme; |
| banksean | ae3724e | 2025-07-18 16:52:37 +0000 | [diff] [blame] | 182 | }; |
| 183 | |
| 184 | private toggleTheme() { |
| 185 | this.themeService.toggleTheme(); |
| 186 | } |
| 187 | |
| banksean | 3eaa433 | 2025-07-19 02:19:06 +0000 | [diff] [blame^] | 188 | private getThemeIcon(): string { |
| 189 | switch (this.currentTheme) { |
| 190 | case "light": |
| 191 | return "☀️"; // Sun |
| 192 | case "dark": |
| 193 | return "🌙"; // Moon |
| 194 | case "system": |
| 195 | return "💻"; // Computer/Laptop |
| 196 | default: |
| 197 | return "💻"; |
| 198 | } |
| 199 | } |
| 200 | |
| banksean | ae3724e | 2025-07-18 16:52:37 +0000 | [diff] [blame] | 201 | render() { |
| 202 | return html` |
| 203 | <button |
| 204 | @click=${this.toggleTheme} |
| banksean | 3eaa433 | 2025-07-19 02:19:06 +0000 | [diff] [blame^] | 205 | class="p-2 rounded-md border border-gray-300 dark:border-gray-600 |
| banksean | ae3724e | 2025-07-18 16:52:37 +0000 | [diff] [blame] | 206 | bg-white dark:bg-gray-800 text-gray-700 dark:text-gray-200 |
| 207 | hover:bg-gray-50 dark:hover:bg-gray-700 transition-colors |
| 208 | focus:outline-none focus:ring-2 focus:ring-blue-500" |
| banksean | 3eaa433 | 2025-07-19 02:19:06 +0000 | [diff] [blame^] | 209 | title="${this.currentTheme} theme - Click to cycle themes" |
| 210 | aria-label="Cycle between light, dark, and system theme" |
| banksean | ae3724e | 2025-07-18 16:52:37 +0000 | [diff] [blame] | 211 | > |
| banksean | 3eaa433 | 2025-07-19 02:19:06 +0000 | [diff] [blame^] | 212 | ${this.getThemeIcon()} |
| banksean | ae3724e | 2025-07-18 16:52:37 +0000 | [diff] [blame] | 213 | </button> |
| 214 | `; |
| 215 | } |
| 216 | } |
| 217 | |
| 218 | declare global { |
| 219 | interface HTMLElementTagNameMap { |
| banksean | 3eaa433 | 2025-07-19 02:19:06 +0000 | [diff] [blame^] | 220 | "sketch-theme-toggle": SketchThemeToggle; |
| banksean | ae3724e | 2025-07-18 16:52:37 +0000 | [diff] [blame] | 221 | } |
| 222 | } |
| 223 | ``` |
| 224 | |
| 225 | #### 4. Initialize Theme in App Shell |
| 226 | |
| banksean | 3eaa433 | 2025-07-19 02:19:06 +0000 | [diff] [blame^] | 227 | Add theme initialization to the main app shell component. This needs to be implemented: |
| banksean | ae3724e | 2025-07-18 16:52:37 +0000 | [diff] [blame] | 228 | |
| 229 | ```typescript |
| banksean | 3eaa433 | 2025-07-19 02:19:06 +0000 | [diff] [blame^] | 230 | // In sketch-app-shell.ts or sketch-app-shell-base.ts |
| banksean | ae3724e | 2025-07-18 16:52:37 +0000 | [diff] [blame] | 231 | import { ThemeService } from "./theme-service.js"; |
| 232 | |
| 233 | connectedCallback() { |
| 234 | super.connectedCallback(); |
| 235 | ThemeService.getInstance().initializeTheme(); |
| 236 | } |
| 237 | ``` |
| 238 | |
| banksean | 3eaa433 | 2025-07-19 02:19:06 +0000 | [diff] [blame^] | 239 | **Note**: This initialization is not yet implemented in the app shell components. |
| 240 | |
| banksean | ae3724e | 2025-07-18 16:52:37 +0000 | [diff] [blame] | 241 | ### Phase 2: Component Updates |
| 242 | |
| 243 | #### Systematic Component Audit |
| 244 | |
| 245 | 1. **Identify all components using color classes** |
| 246 | |
| 247 | - Search for `bg-`, `text-`, `border-`, `ring-`, `divide-` classes |
| 248 | - Document current color usage patterns |
| 249 | |
| 250 | 2. **Add dark mode variants systematically** |
| 251 | |
| 252 | - Start with core components (app shell, chat input, etc.) |
| 253 | - Update classes following the pattern: |
| 254 | |
| 255 | ```typescript |
| 256 | // Before: |
| 257 | class="bg-white text-gray-900 border-gray-200" |
| 258 | |
| 259 | // After: |
| 260 | class="bg-white dark:bg-gray-900 text-gray-900 dark:text-gray-100 border-gray-200 dark:border-gray-700" |
| 261 | ``` |
| 262 | |
| 263 | 3. **Priority order for component updates:** |
| 264 | 1. `sketch-app-shell` - Main container |
| 265 | 2. `sketch-chat-input` - Primary interaction component |
| 266 | 3. `sketch-container-status` - Status indicators |
| 267 | 4. `sketch-call-status` - Call indicators |
| 268 | 5. Demo components and other secondary components |
| 269 | |
| 270 | #### Common Dark Mode Color Mappings |
| 271 | |
| 272 | ```scss |
| 273 | // Light -> Dark mappings |
| 274 | bg-white -> bg-gray-900 |
| 275 | bg-gray-50 -> bg-gray-800 |
| 276 | bg-gray-100 -> bg-gray-800 |
| 277 | bg-gray-200 -> bg-gray-700 |
| 278 | |
| 279 | text-gray-900 -> text-gray-100 |
| 280 | text-gray-800 -> text-gray-200 |
| 281 | text-gray-700 -> text-gray-300 |
| 282 | text-gray-600 -> text-gray-400 |
| 283 | text-gray-500 -> text-gray-500 (neutral) |
| 284 | |
| 285 | border-gray-200 -> border-gray-700 |
| 286 | border-gray-300 -> border-gray-600 |
| 287 | |
| 288 | ring-gray-300 -> ring-gray-600 |
| 289 | ``` |
| 290 | |
| 291 | ### Phase 3: Polish and Testing |
| 292 | |
| 293 | #### 1. Smooth Transitions |
| 294 | |
| 295 | - Add `transition-colors` to interactive elements |
| 296 | - Consider adding a global transition class for theme changes |
| 297 | |
| 298 | #### 2. Accessibility |
| 299 | |
| 300 | - Ensure sufficient contrast ratios in both modes |
| 301 | - Test with screen readers |
| 302 | - Verify focus indicators work in both themes |
| 303 | - Add proper ARIA labels to theme toggle |
| 304 | |
| 305 | #### 3. Testing Checklist |
| 306 | |
| 307 | - [ ] Theme persists across page reloads |
| 308 | - [ ] System preference detection works |
| 309 | - [ ] All components render correctly in both modes |
| 310 | - [ ] Interactive states (hover, focus, active) work in both themes |
| 311 | - [ ] No flash of unstyled content (FOUC) |
| 312 | - [ ] Works across different screen sizes |
| 313 | - [ ] Performance impact is minimal |
| 314 | |
| 315 | ## File Structure |
| 316 | |
| 317 | ``` |
| 318 | src/web-components/ |
| banksean | 3eaa433 | 2025-07-19 02:19:06 +0000 | [diff] [blame^] | 319 | ├── theme-service.ts # Theme management service (✅ implemented) |
| 320 | ├── sketch-theme-toggle.ts # Theme toggle component (✅ implemented) |
| 321 | ├── sketch-tailwind-element.ts # Base class (✅ existing) |
| 322 | └── [other components].ts # Need dark mode variants added |
| banksean | ae3724e | 2025-07-18 16:52:37 +0000 | [diff] [blame] | 323 | ``` |
| 324 | |
| 325 | ## Benefits of This Approach |
| 326 | |
| 327 | - **Incremental**: Can be implemented component by component |
| 328 | - **Standard**: Uses Tailwind's built-in dark mode features |
| 329 | - **Performant**: Class-based approach is efficient |
| 330 | - **Maintainable**: Clear separation of concerns with theme service |
| 331 | - **Accessible**: Respects system preferences by default |
| 332 | - **Consistent**: Follows Sketch's existing component patterns |
| 333 | |
| banksean | 3eaa433 | 2025-07-19 02:19:06 +0000 | [diff] [blame^] | 334 | ## Current Implementation Status |
| banksean | ae3724e | 2025-07-18 16:52:37 +0000 | [diff] [blame] | 335 | |
| banksean | 3eaa433 | 2025-07-19 02:19:06 +0000 | [diff] [blame^] | 336 | ### ✅ Completed: |
| 337 | |
| 338 | - Tailwind configuration with dark mode enabled |
| 339 | - Theme management service with light/dark/system modes |
| 340 | - Theme toggle component with cycling behavior |
| 341 | - Base `SketchTailwindElement` class |
| 342 | |
| 343 | ### 🚧 Partially Complete: |
| 344 | |
| 345 | - Some components may have dark mode classes |
| 346 | |
| 347 | ### ❌ Still Needed: |
| 348 | |
| 349 | - Theme initialization in app shell components |
| 350 | - Systematic audit and update of all components for dark mode |
| 351 | - Testing and accessibility verification |
| 352 | |
| 353 | ## Next Steps Timeline |
| 354 | |
| 355 | 1. Phase 1 - Add theme initialization to app shell |
| 356 | 2. Phase 2 - Core component updates (systematic audit) |
| 357 | 3. Phase 2 - Secondary component updates |
| 358 | 4. Phase 3 - Polish, testing, and accessibility |
| banksean | ae3724e | 2025-07-18 16:52:37 +0000 | [diff] [blame] | 359 | |
| 360 | ## Notes |
| 361 | |
| banksean | 3eaa433 | 2025-07-19 02:19:06 +0000 | [diff] [blame^] | 362 | - Components extend `SketchTailwindElement` (not `LitElement`) ✅ |
| 363 | - No Shadow DOM usage allows for global Tailwind classes ✅ |
| 364 | - Theme service uses singleton pattern for consistency ✅ |
| 365 | - Theme service supports three modes: light, dark, and system ✅ |
| 366 | - Event system allows components to react to theme changes ✅ |
| 367 | - LocalStorage preserves user preference across sessions ✅ |
| 368 | - Theme toggle cycles through all three modes ✅ |