webui: add dark mode implementation plan
Also implements phase 1 of the plan, which just lays the foundation
for implementing the user-visible changes. This does not include
any dark-mode theme settings for the rest of the web UI, and
while it does inlcude a "sketch-theme-toggle" element, this is
only included in the demo:runner vite server for interactive testing.
It's not included in the app shell base yet.
-SM
---
Documents comprehensive strategy for implementing dark mode in Sketch's
web UI using Tailwind CSS class-based approach.
The plan covers:
- Foundation setup (Tailwind config, theme service, toggle component)
- Systematic component updates with dark mode variants
- Accessibility considerations and testing checklist
- 4-week implementation timeline
Key technical decisions:
- Uses SketchTailwindElement base class following existing patterns
- Singleton theme service with event system for component coordination
- Respects system preferences while allowing user override
- Persistent theme storage in localStorage
This provides a roadmap for adding dark mode support while maintaining
consistency with Sketch's existing web component architecture.
Co-Authored-By: sketch <hello@sketch.dev>
Change-ID: s6b69ad95a4394f98k
diff --git a/webui/src/global.css b/webui/src/global.css
index f1d8c73..57c0010 100644
--- a/webui/src/global.css
+++ b/webui/src/global.css
@@ -1 +1,28 @@
@import "tailwindcss";
+@custom-variant dark (&:where(.dark, .dark *));
+
+/* Override dark mode classes to use .dark selector instead of prefers-color-scheme */
+.dark .dark\:bg-gray-900 {
+ background-color: var(--color-gray-900);
+}
+.dark .dark\:bg-gray-800 {
+ background-color: var(--color-gray-800);
+}
+.dark .dark\:text-gray-100 {
+ color: var(--color-gray-100);
+}
+.dark .dark\:text-gray-200 {
+ color: var(--color-gray-200);
+}
+.dark .dark\:text-gray-300 {
+ color: var(--color-gray-300);
+}
+.dark .dark\:border-gray-600 {
+ border-color: var(--color-gray-600);
+}
+.dark .dark\:border-gray-700 {
+ border-color: var(--color-gray-700);
+}
+.dark .dark\:hover\:bg-gray-700:hover {
+ background-color: var(--color-gray-700);
+}
diff --git a/webui/src/web-components/demo/demo-framework/demo-runner.ts b/webui/src/web-components/demo/demo-framework/demo-runner.ts
index 85b8b5f..0e08af5 100644
--- a/webui/src/web-components/demo/demo-framework/demo-runner.ts
+++ b/webui/src/web-components/demo/demo-framework/demo-runner.ts
@@ -100,6 +100,7 @@
"sketch-todo-panel",
"sketch-tool-calls",
"sketch-view-mode-select",
+ "sketch-theme-toggle",
];
// Filter to only components that actually have demo files
diff --git a/webui/src/web-components/demo/sketch-theme-toggle.demo.ts b/webui/src/web-components/demo/sketch-theme-toggle.demo.ts
new file mode 100644
index 0000000..5cf304a
--- /dev/null
+++ b/webui/src/web-components/demo/sketch-theme-toggle.demo.ts
@@ -0,0 +1,118 @@
+/**
+ * Demo module for theme-toggle component
+ */
+
+import { DemoModule } from "./demo-framework/types";
+import { demoUtils } from "./demo-fixtures/index";
+import { ThemeService } from "../theme-service.js";
+
+const demo: DemoModule = {
+ title: "Theme Toggle Demo",
+ description:
+ "Three-way theme toggle: light mode, dark mode, and system preference",
+ imports: ["../sketch-theme-toggle"],
+ styles: ["/dist/tailwind.css"],
+
+ setup: async (container: HTMLElement) => {
+ // Initialize the theme service
+ const themeService = ThemeService.getInstance();
+ themeService.initializeTheme();
+ // Create demo sections
+ const basicSection = demoUtils.createDemoSection(
+ "Three-Way Theme Toggle",
+ "Click the toggle button to cycle through: light → dark → system → light",
+ );
+
+ const toggleContainer = document.createElement("div");
+ toggleContainer.className =
+ "flex items-center gap-4 p-4 bg-white dark:bg-gray-800 rounded-lg border border-gray-200 dark:border-gray-700";
+ toggleContainer.innerHTML = `
+ <sketch-theme-toggle></sketch-theme-toggle>
+ <div class="text-sm text-gray-600 dark:text-gray-400">
+ <div class="font-medium mb-1">Theme modes:</div>
+ <div class="space-y-1">
+ <div>☀️ Light mode - Always light theme</div>
+ <div>🌙 Dark mode - Always dark theme</div>
+ <div>💻 System theme - Follows OS preference</div>
+ </div>
+ </div>
+ `;
+ basicSection.appendChild(toggleContainer);
+
+ // Visual test elements section
+ const visualSection = demoUtils.createDemoSection(
+ "Visual Test Elements",
+ "Elements that demonstrate the theme switching behavior",
+ );
+
+ const visualContainer = document.createElement("div");
+ visualContainer.className = "space-y-4";
+ visualContainer.innerHTML = `
+ <div class="bg-white dark:bg-gray-800 p-4 rounded border border-gray-200 dark:border-gray-600">
+ <h4 class="font-medium text-gray-900 dark:text-gray-100 mb-2">Test Card</h4>
+ <p class="text-gray-600 dark:text-gray-300">
+ This card should switch between light and dark styling when you toggle the theme.
+ </p>
+ </div>
+
+ <div class="flex gap-3">
+ <button class="px-4 py-2 bg-blue-500 hover:bg-blue-600 text-white rounded transition-colors">
+ Primary Button
+ </button>
+ <button class="px-4 py-2 bg-gray-200 dark:bg-gray-700 hover:bg-gray-300 dark:hover:bg-gray-600 text-gray-700 dark:text-gray-200 rounded transition-colors">
+ Secondary Button
+ </button>
+ </div>
+
+ <div class="grid grid-cols-3 gap-4">
+ <div class="bg-white dark:bg-gray-800 p-3 rounded border border-gray-200 dark:border-gray-600">
+ <div class="text-sm font-medium text-gray-900 dark:text-gray-100">Light Background</div>
+ <div class="text-xs text-gray-500 dark:text-gray-400">Should be dark in dark mode</div>
+ </div>
+ <div class="bg-gray-100 dark:bg-gray-800 p-3 rounded border border-gray-200 dark:border-gray-600">
+ <div class="text-sm font-medium text-gray-900 dark:text-gray-100">Gray Background</div>
+ <div class="text-xs text-gray-500 dark:text-gray-400">Should be darker in dark mode</div>
+ </div>
+ <div class="bg-gray-200 dark:bg-gray-700 p-3 rounded border border-gray-200 dark:border-gray-600">
+ <div class="text-sm font-medium text-gray-900 dark:text-gray-100">Darker Background</div>
+ <div class="text-xs text-gray-500 dark:text-gray-400">Should be lighter in dark mode</div>
+ </div>
+ </div>
+ `;
+ visualSection.appendChild(visualContainer);
+
+ // Features section
+ const featuresSection = demoUtils.createDemoSection(
+ "Features",
+ "Key capabilities of the theme toggle component",
+ );
+
+ const featuresContainer = document.createElement("div");
+ featuresContainer.className =
+ "bg-blue-50 dark:bg-blue-900/20 p-6 rounded-lg border border-blue-200 dark:border-blue-800";
+ featuresContainer.innerHTML = `
+ <ul class="space-y-2 text-sm text-blue-800 dark:text-blue-200">
+ <li>• Three-way toggle: light → dark → system → light</li>
+ <li>• Icons: ☀️ (light), 🌙 (dark), 💻 (system)</li>
+ <li>• System mode follows OS dark/light preference</li>
+ <li>• Theme preference persists across page reloads</li>
+ <li>• Emits theme-changed events for component coordination</li>
+ <li>• Smooth transitions between themes</li>
+ <li>• Uses localStorage for preference storage</li>
+ </ul>
+ `;
+ featuresSection.appendChild(featuresContainer);
+
+ // Add all sections to container
+ container.appendChild(basicSection);
+ container.appendChild(visualSection);
+ container.appendChild(featuresSection);
+ },
+
+ cleanup: async () => {
+ // Clean up any event listeners or resources if needed
+ // The theme toggle component handles its own cleanup
+ },
+};
+
+export default demo;
diff --git a/webui/src/web-components/sketch-theme-toggle.ts b/webui/src/web-components/sketch-theme-toggle.ts
new file mode 100644
index 0000000..7e428fa
--- /dev/null
+++ b/webui/src/web-components/sketch-theme-toggle.ts
@@ -0,0 +1,100 @@
+import { html } from "lit";
+import { customElement, state } from "lit/decorators.js";
+import { SketchTailwindElement } from "./sketch-tailwind-element.js";
+import { ThemeService, ThemeMode } from "./theme-service.js";
+
+@customElement("sketch-theme-toggle")
+export class SketchThemeToggle extends SketchTailwindElement {
+ @state() private currentTheme: ThemeMode = "system";
+ @state() private effectiveTheme: "light" | "dark" = "light";
+
+ private themeService = ThemeService.getInstance();
+
+ connectedCallback() {
+ super.connectedCallback();
+ this.updateThemeState();
+
+ // 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.currentTheme = e.detail.theme;
+ this.effectiveTheme = e.detail.effectiveTheme;
+ };
+
+ private updateThemeState() {
+ this.currentTheme = this.themeService.getTheme();
+ this.effectiveTheme = this.themeService.getEffectiveTheme();
+ }
+
+ private toggleTheme() {
+ this.themeService.toggleTheme();
+ }
+
+ private getThemeIcon(): string {
+ switch (this.currentTheme) {
+ case "light":
+ return "\u2600\ufe0f"; // Sun
+ case "dark":
+ return "\ud83c\udf19"; // Moon
+ case "system":
+ return "\ud83d\udcbb"; // Computer/Laptop
+ default:
+ return "\ud83d\udcbb";
+ }
+ }
+
+ private getThemeLabel(): string {
+ switch (this.currentTheme) {
+ case "light":
+ return "Light mode";
+ case "dark":
+ return "Dark mode";
+ case "system":
+ return `System theme (${this.effectiveTheme})`;
+ default:
+ return "System theme";
+ }
+ }
+
+ private getNextThemeLabel(): string {
+ switch (this.currentTheme) {
+ case "light":
+ return "Switch to dark mode";
+ case "dark":
+ return "Switch to system theme";
+ case "system":
+ return "Switch to light mode";
+ default:
+ return "Switch theme";
+ }
+ }
+
+ 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="${this.getThemeLabel()} - ${this.getNextThemeLabel()}"
+ aria-label="${this.getNextThemeLabel()}"
+ >
+ ${this.getThemeIcon()}
+ </button>
+ `;
+ }
+}
+
+declare global {
+ interface HTMLElementTagNameMap {
+ "sketch-theme-toggle": SketchThemeToggle;
+ }
+}
diff --git a/webui/src/web-components/theme-service.ts b/webui/src/web-components/theme-service.ts
new file mode 100644
index 0000000..f002572
--- /dev/null
+++ b/webui/src/web-components/theme-service.ts
@@ -0,0 +1,126 @@
+export type ThemeMode = "light" | "dark" | "system";
+
+export class ThemeService {
+ private static instance: ThemeService;
+ private systemPrefersDark = false;
+ private systemMediaQuery: MediaQueryList;
+
+ private constructor() {
+ this.systemMediaQuery = window.matchMedia("(prefers-color-scheme: dark)");
+ this.systemPrefersDark = this.systemMediaQuery.matches;
+
+ // Listen for system theme changes
+ this.systemMediaQuery.addEventListener("change", (e) => {
+ this.systemPrefersDark = e.matches;
+ // If current theme is 'system', update the applied theme
+ if (this.getTheme() === "system") {
+ this.applyTheme();
+ }
+ });
+ }
+
+ static getInstance(): ThemeService {
+ if (!this.instance) {
+ this.instance = new ThemeService();
+ }
+ return this.instance;
+ }
+
+ /**
+ * Cycle through theme modes: light -> dark -> system -> light
+ */
+ toggleTheme(): void {
+ const currentTheme = this.getTheme();
+ let nextTheme: ThemeMode;
+
+ switch (currentTheme) {
+ case "light":
+ nextTheme = "dark";
+ break;
+ case "dark":
+ nextTheme = "system";
+ break;
+ case "system":
+ nextTheme = "light";
+ break;
+ default:
+ nextTheme = "light";
+ }
+
+ this.setTheme(nextTheme);
+ }
+
+ /**
+ * Set the theme mode
+ */
+ setTheme(theme: ThemeMode): void {
+ // Store the theme preference
+ if (theme === "system") {
+ localStorage.removeItem("theme");
+ } else {
+ localStorage.setItem("theme", theme);
+ }
+
+ // Apply the theme
+ this.applyTheme();
+
+ // Dispatch event for components that need to react
+ document.dispatchEvent(
+ new CustomEvent("theme-changed", {
+ detail: {
+ theme,
+ effectiveTheme: this.getEffectiveTheme(),
+ systemPrefersDark: this.systemPrefersDark,
+ },
+ }),
+ );
+ }
+
+ /**
+ * Get the current theme preference (light, dark, or system)
+ */
+ getTheme(): ThemeMode {
+ const saved = localStorage.getItem("theme");
+ if (saved === "light" || saved === "dark") {
+ return saved;
+ }
+ return "system";
+ }
+
+ /**
+ * Get the effective theme (what is actually applied: light or dark)
+ */
+ getEffectiveTheme(): "light" | "dark" {
+ const theme = this.getTheme();
+ if (theme === "system") {
+ return this.systemPrefersDark ? "dark" : "light";
+ }
+ return theme;
+ }
+
+ /**
+ * Check if dark mode is currently active
+ */
+ isDarkMode(): boolean {
+ return this.getEffectiveTheme() === "dark";
+ }
+
+ /**
+ * Apply the current theme to the DOM
+ */
+ private applyTheme(): void {
+ const effectiveTheme = this.getEffectiveTheme();
+ document.documentElement.classList.toggle(
+ "dark",
+ effectiveTheme === "dark",
+ );
+ }
+
+ /**
+ * Initialize the theme system
+ */
+ initializeTheme(): void {
+ // Apply the initial theme
+ this.applyTheme();
+ }
+}