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:
@@ -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";
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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
|
||||
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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} />
|
||||
|
||||
@@ -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 (
|
||||
<>
|
||||
|
||||
111
packages/dashboard/app/components/ThemeSelector.tsx
Normal file
111
packages/dashboard/app/components/ThemeSelector.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
@@ -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();
|
||||
});
|
||||
});
|
||||
|
||||
|
||||
@@ -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 () => {
|
||||
|
||||
@@ -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();
|
||||
});
|
||||
});
|
||||
});
|
||||
253
packages/dashboard/app/hooks/__tests__/useTheme.test.ts
Normal file
253
packages/dashboard/app/hooks/__tests__/useTheme.test.ts
Normal 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");
|
||||
});
|
||||
});
|
||||
166
packages/dashboard/app/hooks/useTheme.ts
Normal file
166
packages/dashboard/app/hooks/useTheme.ts
Normal 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');
|
||||
}
|
||||
})();
|
||||
`;
|
||||
}
|
||||
@@ -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>
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
|
||||
|
||||
|
||||
@@ -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(),
|
||||
})),
|
||||
});
|
||||
|
||||
Reference in New Issue
Block a user