feat(KB-024): add comprehensive theme system to dashboard

- Add ThemePreference type and useTheme hook for theme state management
- Create CSS theme architecture with 8 built-in color themes
- Build ThemeSelector component with live preview and keyboard navigation
- Add theme toggle to Header with quick-switch capability
- Integrate Appearance section into SettingsModal
- Wire theme system into App component with system preference detection
- Add comprehensive tests for useTheme, ThemeSelector, and Header
- Include theming documentation in dashboard README
- Add changeset for patch release
This commit is contained in:
gsxdsm
2026-03-29 20:22:00 -07:00
parent b935f310ce
commit d288e158d5
16 changed files with 1602 additions and 10 deletions

View File

@@ -1,4 +1,4 @@
export { COLUMNS, COLUMN_LABELS, COLUMN_DESCRIPTIONS, VALID_TRANSITIONS, DEFAULT_SETTINGS, THINKING_LEVELS } from "./types.js";
export type { Column, IssueInfo, IssueState, PrInfo, PrStatus, Task, TaskAttachment, TaskCreateInput, TaskDetail, AgentLogEntry, AgentLogType, AgentRole, BoardConfig, MergeResult, Settings, TaskStep, StepStatus, TaskLogEntry, ThinkingLevel, SteeringComment } from "./types.js";
export { COLUMNS, COLUMN_LABELS, COLUMN_DESCRIPTIONS, VALID_TRANSITIONS, DEFAULT_SETTINGS, THINKING_LEVELS, THEME_MODES, COLOR_THEMES } from "./types.js";
export type { Column, IssueInfo, IssueState, PrInfo, PrStatus, Task, TaskAttachment, TaskCreateInput, TaskDetail, AgentLogEntry, AgentLogType, AgentRole, BoardConfig, MergeResult, Settings, TaskStep, StepStatus, TaskLogEntry, ThinkingLevel, SteeringComment, ThemeMode, ColorTheme } from "./types.js";
export { TaskStore } from "./store.js";
export { canTransition, getValidTransitions, resolveDependencyOrder } from "./board.js";

View File

@@ -5,6 +5,23 @@ export type ThinkingLevel = (typeof THINKING_LEVELS)[number];
export const COLUMNS = ["triage", "todo", "in-progress", "in-review", "done", "archived"] as const;
export type Column = (typeof COLUMNS)[number];
/** Theme mode for light/dark/system preference */
export const THEME_MODES = ["dark", "light", "system"] as const;
export type ThemeMode = (typeof THEME_MODES)[number];
/** Color theme options for the dashboard */
export const COLOR_THEMES = [
"default",
"ocean",
"forest",
"sunset",
"berry",
"monochrome",
"high-contrast",
"solarized",
] as const;
export type ColorTheme = (typeof COLOR_THEMES)[number];
export type PrStatus = "open" | "closed" | "merged";
export interface PrInfo {
@@ -228,6 +245,10 @@ export interface Settings {
/** When true, enables ntfy.sh push notifications for task completion and failures.
* Requires ntfyTopic to be set. Default: false. */
ntfyEnabled?: boolean;
/** Theme mode preference: dark, light, or system (follows OS). Default: "dark". */
themeMode?: ThemeMode;
/** Color theme preference for accent colors and styling. Default: "default". */
colorTheme?: ColorTheme;
}
export const DEFAULT_SETTINGS: Settings = {
@@ -250,6 +271,8 @@ export const DEFAULT_SETTINGS: Settings = {
requirePlanApproval: false,
ntfyEnabled: false,
ntfyTopic: undefined,
themeMode: "dark",
colorTheme: "default",
};
export interface BoardConfig {

View File

@@ -44,10 +44,47 @@ The Git Manager provides comprehensive repository visualization and management d
- View operation results and error states
### Configuration
- **Settings Modal**: Configure scheduling, worktrees, build commands, merge preferences, and notifications
- **Settings Modal**: Configure scheduling, worktrees, build commands, merge preferences, notifications, and appearance
- **Notifications**: ntfy.sh integration for push notifications when tasks complete or fail
- **Authentication**: OAuth provider management for AI model access
- **Pause Controls**: Soft pause (stop new work) and hard stop (kill all agents)
- **Theming**: Light/dark/system mode toggle and 8 color themes (see Theming section below)
## Theming
The dashboard supports a comprehensive theming system with both light/dark mode and color theme options.
### Theme Modes
- **Dark** (default): Classic dark theme, GitHub-inspired
- **Light**: Light backgrounds with dark text
- **System**: Automatically follows your operating system preference
Toggle between modes using the theme button in the header (cycles Dark → Light → System) or select from the Appearance section in Settings.
### Color Themes
Choose from 8 distinct color palettes in the Appearance settings:
| Theme | Description |
|-------|-------------|
| **Default** | Classic blue accent colors (GitHub-inspired) |
| **Ocean** | Deep blues with cyan accents |
| **Forest** | Deep greens with emerald accents |
| **Sunset** | Warm oranges and reds |
| **Berry** | Purple/pink tones |
| **Monochrome** | Pure grayscale |
| **High Contrast** | Extreme contrast for accessibility |
| **Solarized** | Classic solarized palette |
### Theme Persistence
Theme preferences are automatically saved to localStorage and persist across sessions. The effective theme is applied immediately to prevent flash of unstyled content.
### Adding New Themes
To add a new color theme:
1. Add the theme to `COLOR_THEMES` in `packages/core/src/types.ts`
2. Add CSS variables in `packages/dashboard/app/styles.css` under `[data-color-theme="your-theme"]`
3. Add the swatch class for the theme picker in the CSS
4. Update `ThemeSelector.tsx` with the new theme option
## Development

View File

@@ -1,5 +1,5 @@
import { useState, useCallback, useEffect } from "react";
import type { TaskDetail, TaskCreateInput, Task } from "@kb/core";
import type { TaskDetail, TaskCreateInput, Task, ThemeMode } from "@kb/core";
import { fetchConfig, fetchSettings, fetchAuthStatus, updateSettings } from "./api";
import { Header } from "./components/Header";
import { Board } from "./components/Board";
@@ -13,6 +13,7 @@ import { GitHubImportModal } from "./components/GitHubImportModal";
import { GitManagerModal } from "./components/GitManagerModal";
import { useTasks } from "./hooks/useTasks";
import { ToastProvider, useToast } from "./hooks/useToast";
import { useTheme } from "./hooks/useTheme";
function AppInner() {
const [isCreating, setIsCreating] = useState(false);
@@ -38,6 +39,17 @@ function AppInner() {
const [githubTokenConfigured, setGithubTokenConfigured] = useState(false);
const { tasks, createTask, moveTask, deleteTask, mergeTask, retryTask, updateTask, duplicateTask, archiveTask, unarchiveTask } = useTasks();
// Theme management
const { themeMode, colorTheme, setThemeMode, setColorTheme } = useTheme();
// Theme toggle handler: cycles Dark → Light → System → Dark
const handleToggleTheme = useCallback(() => {
const cycle: ThemeMode[] = ["dark", "light", "system"];
const currentIndex = cycle.indexOf(themeMode);
const nextMode = cycle[(currentIndex + 1) % cycle.length];
setThemeMode(nextMode);
}, [themeMode, setThemeMode]);
useEffect(() => {
fetchConfig()
.then((cfg) => setMaxConcurrent(cfg.maxConcurrent))
@@ -145,6 +157,8 @@ function AppInner() {
onToggleEnginePause={handleToggleEnginePause}
view={view}
onChangeView={handleChangeView}
themeMode={themeMode}
onToggleTheme={handleToggleTheme}
/>
{view === "board" ? (
<Board
@@ -200,6 +214,10 @@ function AppInner() {
}}
addToast={addToast}
initialSection={settingsInitialSection}
themeMode={themeMode}
colorTheme={colorTheme}
onThemeModeChange={setThemeMode}
onColorThemeChange={setColorTheme}
/>
)}
<GitHubImportModal

View File

@@ -1,4 +1,5 @@
import { Settings, Pause, Play, Square, Download, LayoutGrid, List, Terminal } from "lucide-react";
import { Settings, Pause, Play, Square, Download, LayoutGrid, List, Terminal, Moon, Sun, Monitor } from "lucide-react";
import type { ThemeMode } from "@kb/core";
interface HeaderProps {
onOpenSettings?: () => void;
@@ -11,6 +12,8 @@ interface HeaderProps {
onToggleEnginePause?: () => void;
view?: "board" | "list";
onChangeView?: (view: "board" | "list") => void;
themeMode?: ThemeMode;
onToggleTheme?: () => void;
}
export function Header({
@@ -24,6 +27,8 @@ export function Header({
onToggleEnginePause,
view = "board",
onChangeView,
themeMode = "dark",
onToggleTheme,
}: HeaderProps) {
const hasInProgressTasks = inProgressCount > 0;
@@ -58,6 +63,24 @@ export function Header({
</button>
</div>
)}
{/* Theme Toggle */}
{onToggleTheme && (
<button
className="btn-icon"
onClick={onToggleTheme}
title={`Toggle theme (${themeMode === "dark" ? "Dark" : themeMode === "light" ? "Light" : "System"})`}
aria-label={`Toggle theme (${themeMode === "dark" ? "Dark" : themeMode === "light" ? "Light" : "System"})`}
data-testid="theme-toggle-btn"
>
{themeMode === "dark" ? (
<Moon size={16} />
) : themeMode === "light" ? (
<Sun size={16} />
) : (
<Monitor size={16} />
)}
</button>
)}
{/* Import from GitHub */}
<button className="btn-icon" onClick={onOpenGitHubImport} title="Import from GitHub">
<Download size={16} />

View File

@@ -1,9 +1,10 @@
import { useState, useEffect, useCallback, useRef } from "react";
import { THINKING_LEVELS } from "@kb/core";
import type { Settings } from "@kb/core";
import type { Settings, ThemeMode, ColorTheme } from "@kb/core";
import { fetchSettings, updateSettings, fetchAuthStatus, loginProvider, logoutProvider, fetchModels } from "../api";
import type { AuthProvider, ModelInfo } from "../api";
import type { ToastType } from "../hooks/useToast";
import { ThemeSelector } from "./ThemeSelector";
/**
* Settings sections configuration.
@@ -15,16 +16,19 @@ import type { ToastType } from "../hooks/useToast";
*
* Sections:
* - general: Task prefix configuration
* - model: Default AI model selection
* - appearance: Theme and color settings
* - scheduling: Concurrency, poll interval, file overlap serialization
* - worktrees: Worktree limits, init commands, recycling
* - commands: Test and build command configuration
* - merge: Auto-merge settings
* - model: Default AI model selection for agent sessions
* - notifications: ntfy.sh notification settings
* - authentication: OAuth provider status, login/logout (operates independently of Save)
*/
const SETTINGS_SECTIONS = [
{ id: "general", label: "General" },
{ id: "model", label: "Model" },
{ id: "appearance", label: "Appearance" },
{ id: "scheduling", label: "Scheduling" },
{ id: "worktrees", label: "Worktrees" },
{ id: "commands", label: "Commands" },
@@ -40,9 +44,25 @@ interface SettingsModalProps {
addToast: (message: string, type?: ToastType) => void;
/** Optional section to show when the modal first opens. Defaults to "general". */
initialSection?: SectionId;
/** Current theme mode */
themeMode?: ThemeMode;
/** Current color theme */
colorTheme?: ColorTheme;
/** Called when theme mode changes */
onThemeModeChange?: (mode: ThemeMode) => void;
/** Called when color theme changes */
onColorThemeChange?: (theme: ColorTheme) => void;
}
export function SettingsModal({ onClose, addToast, initialSection }: SettingsModalProps) {
export function SettingsModal({
onClose,
addToast,
initialSection,
themeMode = "dark",
colorTheme = "default",
onThemeModeChange,
onColorThemeChange,
}: SettingsModalProps) {
const [form, setForm] = useState<Settings & { worktreeInitCommand?: string }>({ maxConcurrent: 2, maxWorktrees: 4, pollIntervalMs: 15000, groupOverlappingFiles: false, autoMerge: true, recycleWorktrees: false, includeTaskIdInCommit: true, worktreeInitCommand: "" });
const [loading, setLoading] = useState(true);
const [activeSection, setActiveSection] = useState<SectionId>(initialSection ?? SETTINGS_SECTIONS[0].id);
@@ -302,6 +322,18 @@ export function SettingsModal({ onClose, addToast, initialSection }: SettingsMod
</>
);
}
case "appearance":
return (
<>
<h4 className="settings-section-heading">Appearance</h4>
<ThemeSelector
themeMode={themeMode}
colorTheme={colorTheme}
onThemeModeChange={onThemeModeChange || (() => {})}
onColorThemeChange={onColorThemeChange || (() => {})}
/>
</>
);
case "scheduling":
return (
<>

View File

@@ -0,0 +1,111 @@
import { useCallback } from "react";
import { Sun, Moon, Monitor } from "lucide-react";
import type { ThemeMode, ColorTheme } from "@kb/core";
interface ThemeSelectorProps {
themeMode: ThemeMode;
colorTheme: ColorTheme;
onThemeModeChange: (mode: ThemeMode) => void;
onColorThemeChange: (theme: ColorTheme) => void;
}
const THEME_MODES: { value: ThemeMode; label: string; icon: typeof Sun }[] = [
{ value: "light", label: "Light", icon: Sun },
{ value: "dark", label: "Dark", icon: Moon },
{ value: "system", label: "System", icon: Monitor },
];
const COLOR_THEMES: { value: ColorTheme; label: string; className: string }[] = [
{ value: "default", label: "Default", className: "theme-swatch-default" },
{ value: "ocean", label: "Ocean", className: "theme-swatch-ocean" },
{ value: "forest", label: "Forest", className: "theme-swatch-forest" },
{ value: "sunset", label: "Sunset", className: "theme-swatch-sunset" },
{ value: "berry", label: "Berry", className: "theme-swatch-berry" },
{ value: "monochrome", label: "Mono", className: "theme-swatch-monochrome" },
{ value: "high-contrast", label: "High Contrast", className: "theme-swatch-high-contrast" },
{ value: "solarized", label: "Solarized", className: "theme-swatch-solarized" },
];
/**
* ThemeSelector component for choosing light/dark/system mode and color theme
*/
export function ThemeSelector({
themeMode,
colorTheme,
onThemeModeChange,
onColorThemeChange,
}: ThemeSelectorProps) {
const handleReset = useCallback(() => {
onThemeModeChange("dark");
onColorThemeChange("default");
}, [onThemeModeChange, onColorThemeChange]);
return (
<div className="theme-selector">
{/* Theme Mode Toggle */}
<div className="theme-mode-toggle" role="radiogroup" aria-label="Theme mode">
{THEME_MODES.map(({ value, label, icon: Icon }) => (
<button
key={value}
className={`theme-mode-btn${themeMode === value ? " active" : ""}`}
onClick={() => onThemeModeChange(value)}
aria-pressed={themeMode === value}
aria-label={`${label} mode`}
title={`${label} mode`}
>
<Icon size={16} />
<span>{label}</span>
</button>
))}
</div>
{/* Current Theme Preview */}
<div className="theme-current-preview">
<div className="theme-preview-icon">
{themeMode === "light" ? (
<Sun size={20} />
) : themeMode === "dark" ? (
<Moon size={20} />
) : (
<Monitor size={20} />
)}
</div>
<div className="theme-preview-info">
<div className="theme-preview-label">Current theme</div>
<div className="theme-preview-value">
{themeMode === "system" ? "System" : `${themeMode.charAt(0).toUpperCase() + themeMode.slice(1)}`}
{" / "}
{COLOR_THEMES.find((t) => t.value === colorTheme)?.label}
</div>
</div>
</div>
{/* Color Theme Grid */}
<div className="theme-section-title">Color Theme</div>
<div className="theme-grid" role="radiogroup" aria-label="Color theme">
{COLOR_THEMES.map(({ value, label, className }) => (
<button
key={value}
className={`theme-option${colorTheme === value ? " active" : ""}`}
onClick={() => onColorThemeChange(value)}
aria-pressed={colorTheme === value}
aria-label={`${label} theme`}
title={label}
>
<div className={`theme-option-swatch ${className}`} />
<span className="theme-option-label">{label}</span>
</button>
))}
</div>
{/* Reset Button */}
<button
className="theme-reset-btn"
onClick={handleReset}
aria-label="Reset to default theme"
>
<span>Reset to defaults</span>
</button>
</div>
);
}

View File

@@ -187,5 +187,70 @@ describe("Header", () => {
expect(boardBtn.className).not.toContain("active");
expect(boardBtn.getAttribute("aria-pressed")).toBe("false");
});
// ── Theme Toggle ─────────────────────────────────────────────────
it("renders theme toggle button when onToggleTheme is provided", () => {
const onToggleTheme = vi.fn();
render(<Header themeMode="dark" onToggleTheme={onToggleTheme} />);
const btn = screen.getByTestId("theme-toggle-btn");
expect(btn).toBeDefined();
});
it("does not render theme toggle when onToggleTheme is not provided", () => {
render(<Header />);
const btn = screen.queryByTestId("theme-toggle-btn");
expect(btn).toBeNull();
});
it("calls onToggleTheme when theme toggle button is clicked", () => {
const onToggleTheme = vi.fn();
render(<Header themeMode="dark" onToggleTheme={onToggleTheme} />);
const btn = screen.getByTestId("theme-toggle-btn");
fireEvent.click(btn);
expect(onToggleTheme).toHaveBeenCalledOnce();
});
it("shows Moon icon for dark mode", () => {
const onToggleTheme = vi.fn();
render(<Header themeMode="dark" onToggleTheme={onToggleTheme} />);
const btn = screen.getByTestId("theme-toggle-btn");
expect(btn.querySelector("svg")).toBeDefined();
});
it("shows Sun icon for light mode", () => {
const onToggleTheme = vi.fn();
render(<Header themeMode="light" onToggleTheme={onToggleTheme} />);
const btn = screen.getByTestId("theme-toggle-btn");
expect(btn.querySelector("svg")).toBeDefined();
});
it("shows Monitor icon for system mode", () => {
const onToggleTheme = vi.fn();
render(<Header themeMode="system" onToggleTheme={onToggleTheme} />);
const btn = screen.getByTestId("theme-toggle-btn");
expect(btn.querySelector("svg")).toBeDefined();
});
it("shows correct title for dark mode", () => {
const onToggleTheme = vi.fn();
render(<Header themeMode="dark" onToggleTheme={onToggleTheme} />);
const btn = screen.getByTitle("Toggle theme (Dark)");
expect(btn).toBeDefined();
});
it("shows correct title for light mode", () => {
const onToggleTheme = vi.fn();
render(<Header themeMode="light" onToggleTheme={onToggleTheme} />);
const btn = screen.getByTitle("Toggle theme (Light)");
expect(btn).toBeDefined();
});
it("shows correct title for system mode", () => {
const onToggleTheme = vi.fn();
render(<Header themeMode="system" onToggleTheme={onToggleTheme} />);
const btn = screen.getByTitle("Toggle theme (System)");
expect(btn).toBeDefined();
});
});

View File

@@ -690,10 +690,10 @@ describe("SettingsModal", () => {
const sidebar = container.querySelector(".settings-sidebar");
expect(sidebar).toBeTruthy();
const navItems = sidebar!.querySelectorAll(".settings-nav-item");
expect(navItems.length).toBe(8);
expect(navItems.length).toBe(9);
const labels = Array.from(navItems).map((el) => el.textContent);
expect(labels).toEqual(["General", "Model", "Scheduling", "Worktrees", "Commands", "Merge", "Notifications", "Authentication"]);
expect(labels).toEqual(["General", "Model", "Appearance", "Scheduling", "Worktrees", "Commands", "Merge", "Notifications", "Authentication"]);
});
it("has .settings-content as sibling of .settings-sidebar", async () => {

View File

@@ -0,0 +1,255 @@
import { describe, it, expect, vi } from "vitest";
import { render, screen, fireEvent } from "@testing-library/react";
import { ThemeSelector } from "../ThemeSelector";
describe("ThemeSelector", () => {
it("renders theme mode toggle buttons", () => {
render(
<ThemeSelector
themeMode="dark"
colorTheme="default"
onThemeModeChange={vi.fn()}
onColorThemeChange={vi.fn()}
/>
);
expect(screen.getByLabelText("Light mode")).toBeDefined();
expect(screen.getByLabelText("Dark mode")).toBeDefined();
expect(screen.getByLabelText("System mode")).toBeDefined();
});
it("marks current theme mode as active", () => {
render(
<ThemeSelector
themeMode="light"
colorTheme="default"
onThemeModeChange={vi.fn()}
onColorThemeChange={vi.fn()}
/>
);
const lightBtn = screen.getByLabelText("Light mode");
expect(lightBtn.className).toContain("active");
expect(lightBtn.getAttribute("aria-pressed")).toBe("true");
});
it("marks non-active theme modes as not pressed", () => {
render(
<ThemeSelector
themeMode="dark"
colorTheme="default"
onThemeModeChange={vi.fn()}
onColorThemeChange={vi.fn()}
/>
);
const lightBtn = screen.getByLabelText("Light mode");
expect(lightBtn.className).not.toContain("active");
expect(lightBtn.getAttribute("aria-pressed")).toBe("false");
});
it("calls onThemeModeChange when a mode is clicked", () => {
const onThemeModeChange = vi.fn();
render(
<ThemeSelector
themeMode="dark"
colorTheme="default"
onThemeModeChange={onThemeModeChange}
onColorThemeChange={vi.fn()}
/>
);
fireEvent.click(screen.getByLabelText("Light mode"));
expect(onThemeModeChange).toHaveBeenCalledWith("light");
fireEvent.click(screen.getByLabelText("System mode"));
expect(onThemeModeChange).toHaveBeenCalledWith("system");
});
it("renders all color theme options", () => {
render(
<ThemeSelector
themeMode="dark"
colorTheme="default"
onThemeModeChange={vi.fn()}
onColorThemeChange={vi.fn()}
/>
);
expect(screen.getByLabelText("Default theme")).toBeDefined();
expect(screen.getByLabelText("Ocean theme")).toBeDefined();
expect(screen.getByLabelText("Forest theme")).toBeDefined();
expect(screen.getByLabelText("Sunset theme")).toBeDefined();
expect(screen.getByLabelText("Berry theme")).toBeDefined();
expect(screen.getByLabelText("Mono theme")).toBeDefined();
expect(screen.getByLabelText("High Contrast theme")).toBeDefined();
expect(screen.getByLabelText("Solarized theme")).toBeDefined();
});
it("marks current color theme as active", () => {
render(
<ThemeSelector
themeMode="dark"
colorTheme="ocean"
onThemeModeChange={vi.fn()}
onColorThemeChange={vi.fn()}
/>
);
const oceanBtn = screen.getByLabelText("Ocean theme");
expect(oceanBtn.className).toContain("active");
expect(oceanBtn.getAttribute("aria-pressed")).toBe("true");
});
it("calls onColorThemeChange when a color theme is clicked", () => {
const onColorThemeChange = vi.fn();
render(
<ThemeSelector
themeMode="dark"
colorTheme="default"
onThemeModeChange={vi.fn()}
onColorThemeChange={onColorThemeChange}
/>
);
fireEvent.click(screen.getByLabelText("Forest theme"));
expect(onColorThemeChange).toHaveBeenCalledWith("forest");
fireEvent.click(screen.getByLabelText("Berry theme"));
expect(onColorThemeChange).toHaveBeenCalledWith("berry");
});
it("displays current theme preview", () => {
render(
<ThemeSelector
themeMode="dark"
colorTheme="ocean"
onThemeModeChange={vi.fn()}
onColorThemeChange={vi.fn()}
/>
);
expect(screen.getByText(/Current theme/)).toBeDefined();
expect(screen.getByText(/Dark \/ Ocean/)).toBeDefined();
});
it("displays system theme in preview when system mode", () => {
render(
<ThemeSelector
themeMode="system"
colorTheme="solarized"
onThemeModeChange={vi.fn()}
onColorThemeChange={vi.fn()}
/>
);
expect(screen.getByText(/System \/ Solarized/)).toBeDefined();
});
it("displays light theme in preview when light mode", () => {
render(
<ThemeSelector
themeMode="light"
colorTheme="forest"
onThemeModeChange={vi.fn()}
onColorThemeChange={vi.fn()}
/>
);
expect(screen.getByText(/Light \/ Forest/)).toBeDefined();
});
it("shows correct icon for dark mode in preview", () => {
render(
<ThemeSelector
themeMode="dark"
colorTheme="default"
onThemeModeChange={vi.fn()}
onColorThemeChange={vi.fn()}
/>
);
const previewIcon = screen.getByText(/Current theme/).closest(".theme-current-preview")?.querySelector("svg");
expect(previewIcon).toBeDefined();
});
it("shows correct icon for light mode in preview", () => {
render(
<ThemeSelector
themeMode="light"
colorTheme="default"
onThemeModeChange={vi.fn()}
onColorThemeChange={vi.fn()}
/>
);
const previewIcon = screen.getByText(/Current theme/).closest(".theme-current-preview")?.querySelector("svg");
expect(previewIcon).toBeDefined();
});
it("shows correct icon for system mode in preview", () => {
render(
<ThemeSelector
themeMode="system"
colorTheme="default"
onThemeModeChange={vi.fn()}
onColorThemeChange={vi.fn()}
/>
);
const previewIcon = screen.getByText(/Current theme/).closest(".theme-current-preview")?.querySelector("svg");
expect(previewIcon).toBeDefined();
});
it("renders reset to defaults button", () => {
render(
<ThemeSelector
themeMode="light"
colorTheme="ocean"
onThemeModeChange={vi.fn()}
onColorThemeChange={vi.fn()}
/>
);
expect(screen.getByLabelText("Reset to default theme")).toBeDefined();
});
it("calls both change handlers when reset is clicked", () => {
const onThemeModeChange = vi.fn();
const onColorThemeChange = vi.fn();
render(
<ThemeSelector
themeMode="light"
colorTheme="ocean"
onThemeModeChange={onThemeModeChange}
onColorThemeChange={onColorThemeChange}
/>
);
fireEvent.click(screen.getByLabelText("Reset to default theme"));
expect(onThemeModeChange).toHaveBeenCalledWith("dark");
expect(onColorThemeChange).toHaveBeenCalledWith("default");
});
it("each color theme has a swatch", () => {
render(
<ThemeSelector
themeMode="dark"
colorTheme="default"
onThemeModeChange={vi.fn()}
onColorThemeChange={vi.fn()}
/>
);
// Query all buttons in theme-grid that have aria-pressed (these are the color theme buttons)
const themeOptions = screen.getAllByRole("button").filter(
(btn) => btn.className.includes("theme-option")
);
expect(themeOptions.length).toBe(8);
themeOptions.forEach((btn) => {
const swatch = btn.querySelector(".theme-option-swatch");
expect(swatch).toBeDefined();
});
});
});

View File

@@ -0,0 +1,253 @@
import { describe, it, expect, vi, beforeEach, afterEach } from "vitest";
import { renderHook, act } from "@testing-library/react";
import { useTheme, getThemeInitScript } from "../useTheme";
describe("useTheme", () => {
// Mock localStorage
let localStorageMock: Record<string, string> = {};
// Mock matchMedia
let matchMediaListeners: Array<(e: { matches: boolean }) => void> = [];
let currentSystemDark = true;
beforeEach(() => {
// Reset mocks
localStorageMock = {};
matchMediaListeners = [];
currentSystemDark = true;
// Mock localStorage
vi.stubGlobal("localStorage", {
getItem: (key: string) => localStorageMock[key] || null,
setItem: (key: string, value: string) => {
localStorageMock[key] = value;
},
removeItem: (key: string) => {
delete localStorageMock[key];
},
});
// Mock matchMedia
vi.stubGlobal("matchMedia", (query: string) => ({
matches: query === "(prefers-color-scheme: dark)" ? currentSystemDark : false,
media: query,
onchange: null,
addEventListener: (event: string, listener: (e: { matches: boolean }) => void) => {
if (event === "change") {
matchMediaListeners.push(listener);
}
},
removeEventListener: (event: string, listener: (e: { matches: boolean }) => void) => {
if (event === "change") {
matchMediaListeners = matchMediaListeners.filter((l) => l !== listener);
}
},
dispatchEvent: () => true,
}));
// Clear document attributes
document.documentElement.removeAttribute("data-theme");
document.documentElement.removeAttribute("data-color-theme");
});
afterEach(() => {
vi.unstubAllGlobals();
});
it("initializes with default values when localStorage is empty", () => {
const { result } = renderHook(() => useTheme());
expect(result.current.themeMode).toBe("dark");
expect(result.current.colorTheme).toBe("default");
});
it("initializes from localStorage", () => {
localStorageMock["kb-dashboard-theme-mode"] = "light";
localStorageMock["kb-dashboard-color-theme"] = "ocean";
const { result } = renderHook(() => useTheme());
expect(result.current.themeMode).toBe("light");
expect(result.current.colorTheme).toBe("ocean");
});
it("updates theme mode", () => {
const { result } = renderHook(() => useTheme());
act(() => {
result.current.setThemeMode("light");
});
expect(result.current.themeMode).toBe("light");
expect(localStorageMock["kb-dashboard-theme-mode"]).toBe("light");
});
it("updates color theme", () => {
const { result } = renderHook(() => useTheme());
act(() => {
result.current.setColorTheme("forest");
});
expect(result.current.colorTheme).toBe("forest");
expect(localStorageMock["kb-dashboard-color-theme"]).toBe("forest");
});
it("sets data-theme attribute on document", () => {
renderHook(() => useTheme());
expect(document.documentElement.getAttribute("data-theme")).toBe("dark");
});
it("sets data-color-theme attribute on document", () => {
localStorageMock["kb-dashboard-color-theme"] = "sunset";
renderHook(() => useTheme());
expect(document.documentElement.getAttribute("data-color-theme")).toBe("sunset");
});
it("handles system theme mode by setting effective theme", () => {
currentSystemDark = false;
localStorageMock["kb-dashboard-theme-mode"] = "system";
renderHook(() => useTheme());
// When system is light, data-theme should be "light"
expect(document.documentElement.getAttribute("data-theme")).toBe("light");
});
it("detects system dark preference", () => {
currentSystemDark = true;
const { result } = renderHook(() => useTheme());
expect(result.current.isSystemDark).toBe(true);
});
it("detects system light preference", () => {
currentSystemDark = false;
const { result } = renderHook(() => useTheme());
expect(result.current.isSystemDark).toBe(false);
});
it("reacts to system theme changes", () => {
const { result } = renderHook(() => useTheme());
// Initially dark
expect(result.current.isSystemDark).toBe(true);
// Simulate system theme change to light
act(() => {
currentSystemDark = false;
matchMediaListeners.forEach((listener) => listener({ matches: false }));
});
expect(result.current.isSystemDark).toBe(false);
});
it("updates effective theme when system changes in system mode", () => {
localStorageMock["kb-dashboard-theme-mode"] = "system";
const { result } = renderHook(() => useTheme());
// Initially dark
expect(document.documentElement.getAttribute("data-theme")).toBe("dark");
// Simulate system theme change to light
act(() => {
currentSystemDark = false;
matchMediaListeners.forEach((listener) => listener({ matches: false }));
});
// Should update to light
expect(document.documentElement.getAttribute("data-theme")).toBe("light");
});
it("supports all valid theme modes", () => {
const { result } = renderHook(() => useTheme());
act(() => result.current.setThemeMode("dark"));
expect(result.current.themeMode).toBe("dark");
act(() => result.current.setThemeMode("light"));
expect(result.current.themeMode).toBe("light");
act(() => result.current.setThemeMode("system"));
expect(result.current.themeMode).toBe("system");
});
it("supports all valid color themes", () => {
const { result } = renderHook(() => useTheme());
const themes = ["default", "ocean", "forest", "sunset", "berry", "monochrome", "high-contrast", "solarized"];
themes.forEach((theme) => {
act(() => result.current.setColorTheme(theme as typeof themes[number]));
expect(result.current.colorTheme).toBe(theme);
});
});
it("ignores invalid theme mode in localStorage", () => {
localStorageMock["kb-dashboard-theme-mode"] = "invalid";
const { result } = renderHook(() => useTheme());
expect(result.current.themeMode).toBe("dark");
});
it("ignores invalid color theme in localStorage", () => {
localStorageMock["kb-dashboard-color-theme"] = "invalid-theme";
const { result } = renderHook(() => useTheme());
expect(result.current.colorTheme).toBe("default");
});
it("falls back to defaults when localStorage throws", () => {
vi.stubGlobal("localStorage", {
getItem: () => {
throw new Error("localStorage disabled");
},
setItem: () => {
throw new Error("localStorage disabled");
},
removeItem: () => {
throw new Error("localStorage disabled");
},
});
const { result } = renderHook(() => useTheme());
expect(result.current.themeMode).toBe("dark");
expect(result.current.colorTheme).toBe("default");
});
});
describe("getThemeInitScript", () => {
it("returns a script string", () => {
const script = getThemeInitScript();
expect(typeof script).toBe("string");
expect(script).toContain("localStorage");
expect(script).toContain("data-theme");
expect(script).toContain("data-color-theme");
});
it("includes the correct localStorage keys", () => {
const script = getThemeInitScript();
expect(script).toContain("kb-dashboard-theme-mode");
expect(script).toContain("kb-dashboard-color-theme");
});
it("handles system theme in script", () => {
const script = getThemeInitScript();
expect(script).toContain("prefers-color-scheme");
expect(script).toContain("systemDark");
expect(script).toContain("effectiveMode");
});
});

View File

@@ -0,0 +1,166 @@
import { useState, useEffect, useCallback, useLayoutEffect } from "react";
import type { ThemeMode, ColorTheme } from "@kb/core";
const THEME_MODE_STORAGE_KEY = "kb-dashboard-theme-mode";
const COLOR_THEME_STORAGE_KEY = "kb-dashboard-color-theme";
// Check if we're in a browser environment
const isBrowser = typeof window !== "undefined";
// Use useLayoutEffect on client, useEffect on server (no-op)
const useIsomorphicLayoutEffect = isBrowser ? useLayoutEffect : useEffect;
interface UseThemeReturn {
themeMode: ThemeMode;
colorTheme: ColorTheme;
setThemeMode: (mode: ThemeMode) => void;
setColorTheme: (theme: ColorTheme) => void;
isSystemDark: boolean;
}
/**
* Get the effective theme mode (resolves "system" to actual dark/light value)
*/
function getEffectiveThemeMode(mode: ThemeMode, systemIsDark: boolean): "dark" | "light" {
if (mode === "system") {
return systemIsDark ? "dark" : "light";
}
return mode;
}
/**
* Apply theme attributes to document.documentElement
* Call this immediately to prevent flash of wrong theme
*/
function applyThemeAttributes(themeMode: ThemeMode, colorTheme: ColorTheme, systemIsDark: boolean): void {
if (!isBrowser) return;
const effectiveMode = getEffectiveThemeMode(themeMode, systemIsDark);
document.documentElement.setAttribute("data-theme", effectiveMode);
document.documentElement.setAttribute("data-color-theme", colorTheme);
}
/**
* Custom hook for theme management
* Handles localStorage persistence, system preference detection, and theme application
*/
export function useTheme(): UseThemeReturn {
// Initialize from localStorage or defaults
const [themeMode, setThemeModeState] = useState<ThemeMode>(() => {
if (!isBrowser) return "dark";
try {
const saved = localStorage.getItem(THEME_MODE_STORAGE_KEY);
if (saved === "dark" || saved === "light" || saved === "system") {
return saved;
}
} catch {
// localStorage not available, use default
}
return "dark";
});
const [colorTheme, setColorThemeState] = useState<ColorTheme>(() => {
if (!isBrowser) return "default";
try {
const saved = localStorage.getItem(COLOR_THEME_STORAGE_KEY);
const validThemes: ColorTheme[] = [
"default",
"ocean",
"forest",
"sunset",
"berry",
"monochrome",
"high-contrast",
"solarized",
];
if (saved && validThemes.includes(saved as ColorTheme)) {
return saved as ColorTheme;
}
} catch {
// localStorage not available, use default
}
return "default";
});
// Track system color scheme preference
const [isSystemDark, setIsSystemDark] = useState<boolean>(() => {
if (!isBrowser) return true;
return window.matchMedia("(prefers-color-scheme: dark)").matches;
});
// Listen to system color scheme changes
useEffect(() => {
if (!isBrowser) return;
const mediaQuery = window.matchMedia("(prefers-color-scheme: dark)");
const handleChange = (e: MediaQueryListEvent) => {
setIsSystemDark(e.matches);
};
mediaQuery.addEventListener("change", handleChange);
return () => mediaQuery.removeEventListener("change", handleChange);
}, []);
// Apply theme immediately on mount and when theme changes
useIsomorphicLayoutEffect(() => {
applyThemeAttributes(themeMode, colorTheme, isSystemDark);
}, [themeMode, colorTheme, isSystemDark]);
// Persist theme to localStorage
useEffect(() => {
if (!isBrowser) return;
try {
localStorage.setItem(THEME_MODE_STORAGE_KEY, themeMode);
} catch {
// localStorage not available, skip persistence
}
}, [themeMode]);
useEffect(() => {
if (!isBrowser) return;
try {
localStorage.setItem(COLOR_THEME_STORAGE_KEY, colorTheme);
} catch {
// localStorage not available, skip persistence
}
}, [colorTheme]);
// Wrapper setters
const setThemeMode = useCallback((mode: ThemeMode) => {
setThemeModeState(mode);
}, []);
const setColorTheme = useCallback((theme: ColorTheme) => {
setColorThemeState(theme);
}, []);
return {
themeMode,
colorTheme,
setThemeMode,
setColorTheme,
isSystemDark,
};
}
/**
* Utility to apply theme before React hydration
* Call this in a script tag in index.html to prevent theme flash
*/
export function getThemeInitScript(): string {
return `
(function() {
try {
var mode = localStorage.getItem('${THEME_MODE_STORAGE_KEY}') || 'dark';
var colorTheme = localStorage.getItem('${COLOR_THEME_STORAGE_KEY}') || 'default';
var systemDark = window.matchMedia('(prefers-color-scheme: dark)').matches;
var effectiveMode = mode === 'system' ? (systemDark ? 'dark' : 'light') : mode;
document.documentElement.setAttribute('data-theme', effectiveMode);
document.documentElement.setAttribute('data-color-theme', colorTheme);
} catch (e) {
document.documentElement.setAttribute('data-theme', 'dark');
document.documentElement.setAttribute('data-color-theme', 'default');
}
})();
`;
}

View File

@@ -5,6 +5,22 @@
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<title>kb | board</title>
<link rel="icon" type="image/svg+xml" href="/logo.svg" />
<script>
// Theme initialization - runs before React to prevent flash
(function() {
try {
var mode = localStorage.getItem('kb-dashboard-theme-mode') || 'dark';
var colorTheme = localStorage.getItem('kb-dashboard-color-theme') || 'default';
var systemDark = window.matchMedia('(prefers-color-scheme: dark)').matches;
var effectiveMode = mode === 'system' ? (systemDark ? 'dark' : 'light') : mode;
document.documentElement.setAttribute('data-theme', effectiveMode);
document.documentElement.setAttribute('data-color-theme', colorTheme);
} catch (e) {
document.documentElement.setAttribute('data-theme', 'dark');
document.documentElement.setAttribute('data-color-theme', 'default');
}
})();
</script>
</head>
<body>
<div id="root"></div>

View File

@@ -3252,4 +3252,553 @@ body {
font-style: italic;
}
/* ============================================================
THEME SYSTEM
============================================================ */
/* Smooth transitions for theme changes */
html,
html *,
html *::before,
html *::after {
transition: background-color 0.2s ease, color 0.2s ease, border-color 0.2s ease, box-shadow 0.2s ease;
}
/* Disable transitions on specific elements that shouldn't animate */
html .card.dragging,
html .card.dragging *,
html .list-row.dragging,
html .list-row.dragging *,
html .column.drag-over,
html .column.drag-over * {
transition: none !important;
}
/* ============================================================
LIGHT THEME (data-theme="light")
============================================================ */
[data-theme="light"] {
/* Backgrounds */
--bg: #ffffff;
--surface: #f6f8fa;
--card: #ffffff;
--card-hover: #f3f4f6;
--border: #d0d7de;
/* Text */
--text: #1f2328;
--text-muted: #656d76;
--text-dim: #8c959f;
/* Status colors (adjusted for light backgrounds) */
--triage: #9a6700;
--todo: #0969da;
--in-progress: #8250df;
--in-review: #1a7f37;
--done: #6e7781;
/* Feedback colors */
--color-success: #1a7f37;
--color-error: #cf222e;
--color-muted: #6e7781;
/* Shadow for light mode */
--shadow: 0 4px 24px rgba(31, 35, 40, 0.15);
}
/* Light theme specific overrides */
[data-theme="light"] .modal-overlay {
background: rgba(31, 35, 40, 0.5);
}
[data-theme="light"] .modal-header {
background: rgba(246, 248, 250, 0.8);
}
[data-theme="light"] .modal-actions {
background: rgba(246, 248, 250, 0.6);
}
[data-theme="light"] .settings-sidebar {
background: rgba(246, 248, 250, 0.6);
}
[data-theme="light"] .toast-success {
background: #1a7f37;
}
[data-theme="light"] .toast-error {
background: #cf222e;
}
[data-theme="light"] .toast-info {
background: #0969da;
}
[data-theme="light"] .btn-primary {
background: #1a7f37;
border-color: #1f883d;
}
[data-theme="light"] .btn-primary:hover {
background: #1f883d;
box-shadow: 0 0 8px rgba(26, 127, 55, 0.3);
}
[data-theme="light"] .btn-danger {
background: #cf222e;
border-color: #a40e26;
}
[data-theme="light"] .btn-danger:hover {
background: #a40e26;
box-shadow: 0 0 8px rgba(207, 34, 46, 0.3);
}
[data-theme="light"] .btn-warning {
background: #9a6700;
border-color: #7d5400;
}
[data-theme="light"] .btn-warning:hover {
background: #7d5400;
box-shadow: 0 0 8px rgba(154, 103, 0, 0.3);
}
[data-theme="light"] .card.agent-active {
box-shadow:
0 0 8px rgba(130, 80, 223, 0.3),
0 0 20px rgba(130, 80, 223, 0.1);
}
@keyframes agent-glow-light {
0%,
100% {
box-shadow:
0 0 8px rgba(130, 80, 223, 0.3),
0 0 20px rgba(130, 80, 223, 0.1);
}
50% {
box-shadow:
0 0 12px rgba(130, 80, 223, 0.5),
0 0 28px rgba(130, 80, 223, 0.2);
}
}
[data-theme="light"] .card.agent-active {
animation: agent-glow-light 2.5s ease-in-out infinite;
}
/* ============================================================
COLOR THEMES (data-color-theme="...")
============================================================ */
/* OCEAN - Deep blues and cyans */
[data-color-theme="ocean"] {
--todo: #00b8d4;
--in-progress: #00e5ff;
--in-review: #00c853;
--triage: #ffab00;
--done: #607d8b;
--color-success: #00c853;
--color-error: #ff5252;
}
[data-color-theme="ocean"][data-theme="light"] {
--todo: #0097a7;
--in-progress: #00bcd4;
--in-review: #4caf50;
--triage: #f57c00;
--done: #546e7a;
--color-success: #4caf50;
--color-error: #f44336;
}
/* FOREST - Deep greens and emeralds */
[data-color-theme="forest"] {
--todo: #34d399;
--in-progress: #10b981;
--in-review: #22c55e;
--triage: #fbbf24;
--done: #6b7280;
--color-success: #22c55e;
--color-error: #ef4444;
}
[data-color-theme="forest"][data-theme="light"] {
--todo: #059669;
--in-progress: #047857;
--in-review: #16a34a;
--triage: #d97706;
--done: #4b5563;
--color-success: #16a34a;
--color-error: #dc2626;
}
/* SUNSET - Warm oranges and reds */
[data-color-theme="sunset"] {
--todo: #ffab00;
--in-progress: #ff6d00;
--in-review: #ff9100;
--triage: #ff3d00;
--done: #8d6e63;
--color-success: #ff9100;
--color-error: #ff1744;
}
[data-color-theme="sunset"][data-theme="light"] {
--todo: #e65100;
--in-progress: #ef6c00;
--in-review: #f57c00;
--triage: #d84315;
--done: #5d4037;
--color-success: #f57c00;
--color-error: #c62828;
}
/* BERRY - Purple/pink tones */
[data-color-theme="berry"] {
--todo: #e040fb;
--in-progress: #ea80fc;
--in-review: #b388ff;
--triage: #ff4081;
--done: #9575cd;
--color-success: #b388ff;
--color-error: #ff5252;
}
[data-color-theme="berry"][data-theme="light"] {
--todo: #7b1fa2;
--in-progress: #8e24aa;
--in-review: #9c27b0;
--triage: #c2185b;
--done: #673ab7;
--color-success: #9c27b0;
--color-error: #d32f2f;
}
/* MONOCHROME - Pure grays */
[data-color-theme="monochrome"] {
--todo: #9e9e9e;
--in-progress: #bdbdbd;
--in-review: #e0e0e0;
--triage: #757575;
--done: #616161;
--color-success: #e0e0e0;
--color-error: #ff5252;
}
[data-color-theme="monochrome"][data-theme="light"] {
--todo: #616161;
--in-progress: #757575;
--in-review: #9e9e9e;
--triage: #424242;
--done: #424242;
--color-success: #616161;
--color-error: #d32f2f;
}
/* HIGH CONTRAST - Extreme contrast for accessibility */
[data-color-theme="high-contrast"] {
--bg: #000000;
--surface: #0a0a0a;
--card: #141414;
--card-hover: #1f1f1f;
--border: #ffffff;
--text: #ffffff;
--text-muted: #cccccc;
--text-dim: #999999;
--todo: #00ffff;
--in-progress: #ff00ff;
--in-review: #00ff00;
--triage: #ffff00;
--done: #ffffff;
--color-success: #00ff00;
--color-error: #ff0000;
}
[data-color-theme="high-contrast"][data-theme="light"] {
--bg: #ffffff;
--surface: #ffffff;
--card: #ffffff;
--card-hover: #f0f0f0;
--border: #000000;
--text: #000000;
--text-muted: #333333;
--text-dim: #666666;
--todo: #0066ff;
--in-progress: #cc00cc;
--in-review: #009900;
--triage: #cc6600;
--done: #000000;
--color-success: #009900;
--color-error: #cc0000;
}
/* SOLARIZED - Classic solarized palette */
[data-color-theme="solarized"] {
--bg: #002b36;
--surface: #073642;
--card: #083c4a;
--card-hover: #094c5e;
--border: #586e75;
--text: #839496;
--text-muted: #657b83;
--text-dim: #586e75;
--todo: #268bd2;
--in-progress: #2aa198;
--in-review: #859900;
--triage: #b58900;
--done: #93a1a1;
--color-success: #859900;
--color-error: #dc322f;
}
[data-color-theme="solarized"][data-theme="light"] {
--bg: #fdf6e3;
--surface: #eee8d5;
--card: #f5efdc;
--card-hover: #e8e2d0;
--border: #93a1a1;
--text: #586e75;
--text-muted: #657b83;
--text-dim: #839496;
--todo: #268bd2;
--in-progress: #2aa198;
--in-review: #859900;
--triage: #b58900;
--done: #93a1a1;
--color-success: #859900;
--color-error: #dc322f;
}
/* ============================================================
THEME SELECTOR COMPONENT STYLES
============================================================ */
.theme-selector {
padding: 0 20px 20px;
}
.theme-mode-toggle {
display: flex;
align-items: center;
gap: 8px;
background: var(--card);
border: 1px solid var(--border);
border-radius: var(--radius);
padding: 4px;
margin-bottom: 20px;
}
.theme-mode-btn {
display: flex;
align-items: center;
justify-content: center;
gap: 6px;
padding: 8px 16px;
background: none;
border: none;
border-radius: 4px;
color: var(--text-muted);
font-size: 13px;
font-weight: 500;
cursor: pointer;
transition: all var(--transition-fast);
flex: 1;
}
.theme-mode-btn:hover {
color: var(--text);
background: var(--card-hover);
}
.theme-mode-btn.active {
background: var(--todo);
color: var(--bg);
}
.theme-mode-btn.active:hover {
background: var(--todo);
color: var(--bg);
}
.theme-section-title {
font-size: 12px;
font-weight: 600;
color: var(--text-muted);
text-transform: uppercase;
letter-spacing: 0.5px;
margin-bottom: 12px;
}
.theme-grid {
display: grid;
grid-template-columns: repeat(4, 1fr);
gap: 12px;
}
@media (max-width: 768px) {
.theme-grid {
grid-template-columns: repeat(2, 1fr);
}
}
.theme-option {
display: flex;
flex-direction: column;
align-items: center;
gap: 8px;
padding: 12px;
background: var(--card);
border: 2px solid var(--border);
border-radius: var(--radius);
cursor: pointer;
transition: all var(--transition-fast);
}
.theme-option:hover {
border-color: var(--todo);
background: var(--card-hover);
}
.theme-option.active {
border-color: var(--todo);
background: rgba(88, 166, 255, 0.1);
}
.theme-option-swatch {
width: 48px;
height: 48px;
border-radius: var(--radius);
border: 2px solid var(--border);
position: relative;
overflow: hidden;
}
.theme-option-swatch::after {
content: "";
position: absolute;
inset: 0;
background: linear-gradient(135deg, var(--bg) 50%, var(--surface) 50%);
}
.theme-option-label {
font-size: 12px;
font-weight: 500;
color: var(--text);
text-align: center;
}
/* Theme preview swatch colors */
.theme-swatch-default {
--bg: #0d1117;
--surface: #161b22;
}
.theme-swatch-ocean {
--bg: #0a1929;
--surface: #132f4c;
}
.theme-swatch-forest {
--bg: #0d2818;
--surface: #1a472a;
}
.theme-swatch-sunset {
--bg: #2d1f1f;
--surface: #4a2c2c;
}
.theme-swatch-berry {
--bg: #1a0b2e;
--surface: #2d1b4e;
}
.theme-swatch-monochrome {
--bg: #0d0d0d;
--surface: #1a1a1a;
}
.theme-swatch-high-contrast {
--bg: #000000;
--surface: #0a0a0a;
}
.theme-swatch-solarized {
--bg: #002b36;
--surface: #073642;
}
/* Reset to defaults button */
.theme-reset-btn {
display: flex;
align-items: center;
justify-content: center;
gap: 8px;
margin-top: 20px;
padding: 10px 16px;
background: var(--card);
border: 1px solid var(--border);
border-radius: var(--radius);
color: var(--text-muted);
font-size: 13px;
font-weight: 500;
cursor: pointer;
transition: all var(--transition-fast);
width: 100%;
}
.theme-reset-btn:hover {
background: var(--card-hover);
color: var(--text);
border-color: var(--text-muted);
}
/* Theme current preview in settings */
.theme-current-preview {
display: flex;
align-items: center;
gap: 12px;
padding: 12px 16px;
background: var(--card);
border: 1px solid var(--border);
border-radius: var(--radius);
margin-bottom: 20px;
}
.theme-preview-icon {
display: flex;
align-items: center;
justify-content: center;
width: 40px;
height: 40px;
background: var(--surface);
border-radius: var(--radius);
color: var(--todo);
}
.theme-preview-info {
flex: 1;
}
.theme-preview-label {
font-size: 12px;
color: var(--text-muted);
margin-bottom: 2px;
}
.theme-preview-value {
font-size: 14px;
font-weight: 600;
color: var(--text);
text-transform: capitalize;
}

View File

@@ -1 +1,33 @@
import "@testing-library/jest-dom";
import { vi } from "vitest";
// Mock localStorage
const localStorageMock: Record<string, string> = {};
Object.defineProperty(window, "localStorage", {
value: {
getItem: (key: string) => localStorageMock[key] || null,
setItem: (key: string, value: string) => {
localStorageMock[key] = value;
},
removeItem: (key: string) => {
delete localStorageMock[key];
},
clear: () => {
Object.keys(localStorageMock).forEach((key) => delete localStorageMock[key]);
},
},
writable: true,
});
// Mock matchMedia
Object.defineProperty(window, "matchMedia", {
writable: true,
value: vi.fn().mockImplementation((query: string) => ({
matches: query === "(prefers-color-scheme: dark)" ? true : false,
media: query,
onchange: null,
addEventListener: vi.fn(),
removeEventListener: vi.fn(),
dispatchEvent: vi.fn(),
})),
});