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:
12
.changeset/theme-system.md
Normal file
12
.changeset/theme-system.md
Normal file
@@ -0,0 +1,12 @@
|
|||||||
|
---
|
||||||
|
"@dustinbyrne/kb": minor
|
||||||
|
---
|
||||||
|
|
||||||
|
Add light mode toggle and theme selector with 8+ attractive color themes
|
||||||
|
|
||||||
|
- New theme modes: Dark, Light, and System (follows OS preference)
|
||||||
|
- 8 color themes: Default, Ocean, Forest, Sunset, Berry, Monochrome, High Contrast, and Solarized
|
||||||
|
- Quick theme toggle button in header
|
||||||
|
- Full theme selector in Settings > Appearance
|
||||||
|
- Theme preferences persist to localStorage
|
||||||
|
- No-FOUC inline script ensures correct theme before page render
|
||||||
@@ -1,4 +1,4 @@
|
|||||||
export { COLUMNS, COLUMN_LABELS, COLUMN_DESCRIPTIONS, VALID_TRANSITIONS, DEFAULT_SETTINGS, THINKING_LEVELS } 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 } 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 { TaskStore } from "./store.js";
|
||||||
export { canTransition, getValidTransitions, resolveDependencyOrder } from "./board.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 const COLUMNS = ["triage", "todo", "in-progress", "in-review", "done", "archived"] as const;
|
||||||
export type Column = (typeof COLUMNS)[number];
|
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 type PrStatus = "open" | "closed" | "merged";
|
||||||
|
|
||||||
export interface PrInfo {
|
export interface PrInfo {
|
||||||
@@ -228,6 +245,10 @@ export interface Settings {
|
|||||||
/** When true, enables ntfy.sh push notifications for task completion and failures.
|
/** When true, enables ntfy.sh push notifications for task completion and failures.
|
||||||
* Requires ntfyTopic to be set. Default: false. */
|
* Requires ntfyTopic to be set. Default: false. */
|
||||||
ntfyEnabled?: boolean;
|
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 = {
|
export const DEFAULT_SETTINGS: Settings = {
|
||||||
@@ -250,6 +271,8 @@ export const DEFAULT_SETTINGS: Settings = {
|
|||||||
requirePlanApproval: false,
|
requirePlanApproval: false,
|
||||||
ntfyEnabled: false,
|
ntfyEnabled: false,
|
||||||
ntfyTopic: undefined,
|
ntfyTopic: undefined,
|
||||||
|
themeMode: "dark",
|
||||||
|
colorTheme: "default",
|
||||||
};
|
};
|
||||||
|
|
||||||
export interface BoardConfig {
|
export interface BoardConfig {
|
||||||
|
|||||||
@@ -44,10 +44,47 @@ The Git Manager provides comprehensive repository visualization and management d
|
|||||||
- View operation results and error states
|
- View operation results and error states
|
||||||
|
|
||||||
### Configuration
|
### 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
|
- **Notifications**: ntfy.sh integration for push notifications when tasks complete or fail
|
||||||
- **Authentication**: OAuth provider management for AI model access
|
- **Authentication**: OAuth provider management for AI model access
|
||||||
- **Pause Controls**: Soft pause (stop new work) and hard stop (kill all agents)
|
- **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
|
## Development
|
||||||
|
|
||||||
|
|||||||
@@ -1,5 +1,5 @@
|
|||||||
import { useState, useCallback, useEffect } from "react";
|
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 { fetchConfig, fetchSettings, fetchAuthStatus, updateSettings } from "./api";
|
||||||
import { Header } from "./components/Header";
|
import { Header } from "./components/Header";
|
||||||
import { Board } from "./components/Board";
|
import { Board } from "./components/Board";
|
||||||
@@ -13,6 +13,7 @@ import { GitHubImportModal } from "./components/GitHubImportModal";
|
|||||||
import { GitManagerModal } from "./components/GitManagerModal";
|
import { GitManagerModal } from "./components/GitManagerModal";
|
||||||
import { useTasks } from "./hooks/useTasks";
|
import { useTasks } from "./hooks/useTasks";
|
||||||
import { ToastProvider, useToast } from "./hooks/useToast";
|
import { ToastProvider, useToast } from "./hooks/useToast";
|
||||||
|
import { useTheme } from "./hooks/useTheme";
|
||||||
|
|
||||||
function AppInner() {
|
function AppInner() {
|
||||||
const [isCreating, setIsCreating] = useState(false);
|
const [isCreating, setIsCreating] = useState(false);
|
||||||
@@ -38,6 +39,17 @@ function AppInner() {
|
|||||||
const [githubTokenConfigured, setGithubTokenConfigured] = useState(false);
|
const [githubTokenConfigured, setGithubTokenConfigured] = useState(false);
|
||||||
const { tasks, createTask, moveTask, deleteTask, mergeTask, retryTask, updateTask, duplicateTask, archiveTask, unarchiveTask } = useTasks();
|
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(() => {
|
useEffect(() => {
|
||||||
fetchConfig()
|
fetchConfig()
|
||||||
.then((cfg) => setMaxConcurrent(cfg.maxConcurrent))
|
.then((cfg) => setMaxConcurrent(cfg.maxConcurrent))
|
||||||
@@ -145,6 +157,8 @@ function AppInner() {
|
|||||||
onToggleEnginePause={handleToggleEnginePause}
|
onToggleEnginePause={handleToggleEnginePause}
|
||||||
view={view}
|
view={view}
|
||||||
onChangeView={handleChangeView}
|
onChangeView={handleChangeView}
|
||||||
|
themeMode={themeMode}
|
||||||
|
onToggleTheme={handleToggleTheme}
|
||||||
/>
|
/>
|
||||||
{view === "board" ? (
|
{view === "board" ? (
|
||||||
<Board
|
<Board
|
||||||
@@ -200,6 +214,10 @@ function AppInner() {
|
|||||||
}}
|
}}
|
||||||
addToast={addToast}
|
addToast={addToast}
|
||||||
initialSection={settingsInitialSection}
|
initialSection={settingsInitialSection}
|
||||||
|
themeMode={themeMode}
|
||||||
|
colorTheme={colorTheme}
|
||||||
|
onThemeModeChange={setThemeMode}
|
||||||
|
onColorThemeChange={setColorTheme}
|
||||||
/>
|
/>
|
||||||
)}
|
)}
|
||||||
<GitHubImportModal
|
<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 {
|
interface HeaderProps {
|
||||||
onOpenSettings?: () => void;
|
onOpenSettings?: () => void;
|
||||||
@@ -11,6 +12,8 @@ interface HeaderProps {
|
|||||||
onToggleEnginePause?: () => void;
|
onToggleEnginePause?: () => void;
|
||||||
view?: "board" | "list";
|
view?: "board" | "list";
|
||||||
onChangeView?: (view: "board" | "list") => void;
|
onChangeView?: (view: "board" | "list") => void;
|
||||||
|
themeMode?: ThemeMode;
|
||||||
|
onToggleTheme?: () => void;
|
||||||
}
|
}
|
||||||
|
|
||||||
export function Header({
|
export function Header({
|
||||||
@@ -24,6 +27,8 @@ export function Header({
|
|||||||
onToggleEnginePause,
|
onToggleEnginePause,
|
||||||
view = "board",
|
view = "board",
|
||||||
onChangeView,
|
onChangeView,
|
||||||
|
themeMode = "dark",
|
||||||
|
onToggleTheme,
|
||||||
}: HeaderProps) {
|
}: HeaderProps) {
|
||||||
const hasInProgressTasks = inProgressCount > 0;
|
const hasInProgressTasks = inProgressCount > 0;
|
||||||
|
|
||||||
@@ -58,6 +63,24 @@ export function Header({
|
|||||||
</button>
|
</button>
|
||||||
</div>
|
</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 */}
|
{/* Import from GitHub */}
|
||||||
<button className="btn-icon" onClick={onOpenGitHubImport} title="Import from GitHub">
|
<button className="btn-icon" onClick={onOpenGitHubImport} title="Import from GitHub">
|
||||||
<Download size={16} />
|
<Download size={16} />
|
||||||
|
|||||||
@@ -1,9 +1,10 @@
|
|||||||
import { useState, useEffect, useCallback, useRef } from "react";
|
import { useState, useEffect, useCallback, useRef } from "react";
|
||||||
import { THINKING_LEVELS } from "@kb/core";
|
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 { fetchSettings, updateSettings, fetchAuthStatus, loginProvider, logoutProvider, fetchModels } from "../api";
|
||||||
import type { AuthProvider, ModelInfo } from "../api";
|
import type { AuthProvider, ModelInfo } from "../api";
|
||||||
import type { ToastType } from "../hooks/useToast";
|
import type { ToastType } from "../hooks/useToast";
|
||||||
|
import { ThemeSelector } from "./ThemeSelector";
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Settings sections configuration.
|
* Settings sections configuration.
|
||||||
@@ -15,16 +16,19 @@ import type { ToastType } from "../hooks/useToast";
|
|||||||
*
|
*
|
||||||
* Sections:
|
* Sections:
|
||||||
* - general: Task prefix configuration
|
* - general: Task prefix configuration
|
||||||
|
* - model: Default AI model selection
|
||||||
|
* - appearance: Theme and color settings
|
||||||
* - scheduling: Concurrency, poll interval, file overlap serialization
|
* - scheduling: Concurrency, poll interval, file overlap serialization
|
||||||
* - worktrees: Worktree limits, init commands, recycling
|
* - worktrees: Worktree limits, init commands, recycling
|
||||||
* - commands: Test and build command configuration
|
* - commands: Test and build command configuration
|
||||||
* - merge: Auto-merge settings
|
* - 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)
|
* - authentication: OAuth provider status, login/logout (operates independently of Save)
|
||||||
*/
|
*/
|
||||||
const SETTINGS_SECTIONS = [
|
const SETTINGS_SECTIONS = [
|
||||||
{ id: "general", label: "General" },
|
{ id: "general", label: "General" },
|
||||||
{ id: "model", label: "Model" },
|
{ id: "model", label: "Model" },
|
||||||
|
{ id: "appearance", label: "Appearance" },
|
||||||
{ id: "scheduling", label: "Scheduling" },
|
{ id: "scheduling", label: "Scheduling" },
|
||||||
{ id: "worktrees", label: "Worktrees" },
|
{ id: "worktrees", label: "Worktrees" },
|
||||||
{ id: "commands", label: "Commands" },
|
{ id: "commands", label: "Commands" },
|
||||||
@@ -40,9 +44,25 @@ interface SettingsModalProps {
|
|||||||
addToast: (message: string, type?: ToastType) => void;
|
addToast: (message: string, type?: ToastType) => void;
|
||||||
/** Optional section to show when the modal first opens. Defaults to "general". */
|
/** Optional section to show when the modal first opens. Defaults to "general". */
|
||||||
initialSection?: SectionId;
|
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 [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 [loading, setLoading] = useState(true);
|
||||||
const [activeSection, setActiveSection] = useState<SectionId>(initialSection ?? SETTINGS_SECTIONS[0].id);
|
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":
|
case "scheduling":
|
||||||
return (
|
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.className).not.toContain("active");
|
||||||
expect(boardBtn.getAttribute("aria-pressed")).toBe("false");
|
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");
|
const sidebar = container.querySelector(".settings-sidebar");
|
||||||
expect(sidebar).toBeTruthy();
|
expect(sidebar).toBeTruthy();
|
||||||
const navItems = sidebar!.querySelectorAll(".settings-nav-item");
|
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);
|
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 () => {
|
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" />
|
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
|
||||||
<title>kb | board</title>
|
<title>kb | board</title>
|
||||||
<link rel="icon" type="image/svg+xml" href="/logo.svg" />
|
<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>
|
</head>
|
||||||
<body>
|
<body>
|
||||||
<div id="root"></div>
|
<div id="root"></div>
|
||||||
|
|||||||
@@ -3252,4 +3252,553 @@ body {
|
|||||||
font-style: italic;
|
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 "@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