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 6c59560a0a
commit 6bfdb6bcde
16 changed files with 1602 additions and 10 deletions

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();
});
});
});