handleTabSelect(tab.id)}
onClose={(e) => handleTabClose(e, tab.id)}
diff --git a/ccw/frontend/src/components/coordinator/CoordinatorEmptyState.tsx b/ccw/frontend/src/components/coordinator/CoordinatorEmptyState.tsx
index a2aedaef..4910ceb0 100644
--- a/ccw/frontend/src/components/coordinator/CoordinatorEmptyState.tsx
+++ b/ccw/frontend/src/components/coordinator/CoordinatorEmptyState.tsx
@@ -32,8 +32,8 @@ export function CoordinatorEmptyState({
className
)}
>
- {/* Animated Background - Using theme colors */}
-
+ {/* Animated Background - Using theme colors with gradient utilities */}
+
{/* Grid Pattern */}
- {/* Animated Gradient Orbs - Using primary color */}
-
+ {/* Animated Gradient Orbs - Using gradient utility classes */}
+
-
+
{/* Main Content */}
diff --git a/ccw/frontend/src/components/layout/AppShell.tsx b/ccw/frontend/src/components/layout/AppShell.tsx
index f2da571b..315b1ef7 100644
--- a/ccw/frontend/src/components/layout/AppShell.tsx
+++ b/ccw/frontend/src/components/layout/AppShell.tsx
@@ -130,6 +130,7 @@ export function AppShell({
};
window.addEventListener('resize', handleResize);
+ // Cleanup: Remove event listener on unmount to prevent memory leak
return () => window.removeEventListener('resize', handleResize);
}, []);
diff --git a/ccw/frontend/src/components/shared/ThemeSelector.tsx b/ccw/frontend/src/components/shared/ThemeSelector.tsx
index 1c9f8c9b..08946de0 100644
--- a/ccw/frontend/src/components/shared/ThemeSelector.tsx
+++ b/ccw/frontend/src/components/shared/ThemeSelector.tsx
@@ -1,32 +1,72 @@
-import React from 'react';
+import React, { useState, useEffect } from 'react';
import { useIntl } from 'react-intl';
import { useTheme } from '@/hooks/useTheme';
import { COLOR_SCHEMES, THEME_MODES, getThemeName } from '@/lib/theme';
import type { ColorScheme, ThemeMode } from '@/lib/theme';
+import { generateThemeFromHue } from '@/lib/colorGenerator';
/**
* Theme Selector Component
* Allows users to select from 4 color schemes (blue/green/orange/purple)
- * and 2 theme modes (light/dark)
+ * and 2 theme modes (light/dark), plus custom hue customization
*
* Features:
- * - 8 total theme combinations
+ * - 8 preset theme combinations + custom hue support
* - Keyboard navigation support (Arrow keys)
* - ARIA labels for accessibility
* - Visual feedback for selected theme
* - System dark mode detection
+ * - Custom hue slider (0-360) with real-time preview
*/
export function ThemeSelector() {
const { formatMessage } = useIntl();
- const { colorScheme, resolvedTheme, setColorScheme, setTheme } = useTheme();
+ const { colorScheme, resolvedTheme, customHue, isCustomTheme, setColorScheme, setTheme, setCustomHue } = useTheme();
+
+ // Local state for preview hue (uncommitted changes)
+ const [previewHue, setPreviewHue] = useState
(customHue);
+
+ // Sync preview with customHue from store
+ useEffect(() => {
+ setPreviewHue(customHue);
+ }, [customHue]);
// Resolved mode is either 'light' or 'dark'
const mode: ThemeMode = resolvedTheme;
+ // Get preview colors for the custom theme swatches
+ const getPreviewColor = (variable: string) => {
+ const hue = previewHue ?? 180; // Default to cyan if null
+ const colors = generateThemeFromHue(hue, mode);
+ const hslValue = colors[variable];
+ return hslValue ? `hsl(${hslValue})` : '#888';
+ };
+
const handleSchemeSelect = (scheme: ColorScheme) => {
+ // When selecting a preset scheme, reset custom hue
+ if (isCustomTheme) {
+ setCustomHue(null);
+ }
setColorScheme(scheme);
};
+ const handleCustomSelect = () => {
+ // Set custom hue to a default value if null
+ if (customHue === null) {
+ setCustomHue(180); // Default cyan
+ }
+ };
+
+ const handleHueSave = () => {
+ if (previewHue !== null) {
+ setCustomHue(previewHue);
+ }
+ };
+
+ const handleHueReset = () => {
+ setCustomHue(null);
+ setPreviewHue(null);
+ };
+
const handleModeSelect = (newMode: ThemeMode) => {
setTheme(newMode);
};
@@ -53,7 +93,7 @@ export function ThemeSelector() {
{formatMessage({ id: 'theme.title.colorScheme' })}
handleSchemeSelect(scheme.id)}
aria-label={formatMessage({ id: 'theme.select.colorScheme' }, { name: formatMessage({ id: `theme.colorScheme.${scheme.id}` }) })}
- aria-selected={colorScheme === scheme.id}
+ aria-selected={colorScheme === scheme.id && !isCustomTheme}
role="radio"
className={`
flex flex-col items-center gap-2 p-3 rounded-lg
transition-all duration-200 border-2
- ${colorScheme === scheme.id
+ ${colorScheme === scheme.id && !isCustomTheme
? 'border-accent bg-surface shadow-md'
: 'border-border bg-bg hover:bg-surface'
}
@@ -87,9 +127,124 @@ export function ThemeSelector() {
))}
+
+ {/* Custom Color Option */}
+
+ {/* Custom Hue Selection - Only shown when custom theme is active */}
+ {isCustomTheme && (
+
+
+ {formatMessage({ id: 'theme.title.customHue' })}
+
+
+ {/* Hue Slider */}
+
+
+
+
+
setPreviewHue(Number(e.target.value))}
+ className="w-full h-2 rounded-lg appearance-none cursor-pointer"
+ style={{
+ background: `linear-gradient(to right,
+ hsl(0, 70%, 60%), hsl(60, 70%, 60%), hsl(120, 70%, 60%),
+ hsl(180, 70%, 60%), hsl(240, 70%, 60%), hsl(300, 70%, 60%), hsl(360, 70%, 60%))`
+ }}
+ aria-label={formatMessage({ id: 'theme.title.customHue' })}
+ />
+
+ {/* Preview Swatches */}
+
+
+ {formatMessage({ id: 'theme.preview' })}:
+
+
+
+
+
+
+ {/* Save and Reset Buttons */}
+
+
+
+
+
+
+ )}
+
{/* Theme Mode Selection */}
diff --git a/ccw/frontend/src/hooks/useTheme.ts b/ccw/frontend/src/hooks/useTheme.ts
index 52103313..b1441696 100644
--- a/ccw/frontend/src/hooks/useTheme.ts
+++ b/ccw/frontend/src/hooks/useTheme.ts
@@ -4,7 +4,7 @@
// Convenient hook for theme management with multi-color scheme support
import { useCallback } from 'react';
-import { useAppStore, selectTheme, selectResolvedTheme } from '../stores/appStore';
+import { useAppStore, selectTheme, selectResolvedTheme, selectCustomHue, selectIsCustomTheme } from '../stores/appStore';
import type { Theme, ColorScheme } from '../types/store';
export interface UseThemeReturn {
@@ -16,10 +16,16 @@ export interface UseThemeReturn {
isDark: boolean;
/** Current color scheme ('blue', 'green', 'orange', 'purple') */
colorScheme: ColorScheme;
+ /** Custom hue value (0-360) for theme customization, null when using preset themes */
+ customHue: number | null;
+ /** Whether the current theme is a custom theme */
+ isCustomTheme: boolean;
/** Set theme preference */
setTheme: (theme: Theme) => void;
/** Set color scheme */
setColorScheme: (scheme: ColorScheme) => void;
+ /** Set custom hue value (0-360) or null to reset to preset theme */
+ setCustomHue: (hue: number | null) => void;
/** Toggle between light and dark (ignores system) */
toggleTheme: () => void;
}
@@ -46,8 +52,11 @@ export function useTheme(): UseThemeReturn {
const theme = useAppStore(selectTheme);
const resolvedTheme = useAppStore(selectResolvedTheme);
const colorScheme = useAppStore((state) => state.colorScheme);
+ const customHue = useAppStore(selectCustomHue);
+ const isCustomTheme = useAppStore(selectIsCustomTheme);
const setThemeAction = useAppStore((state) => state.setTheme);
const setColorSchemeAction = useAppStore((state) => state.setColorScheme);
+ const setCustomHueAction = useAppStore((state) => state.setCustomHue);
const toggleThemeAction = useAppStore((state) => state.toggleTheme);
const setTheme = useCallback(
@@ -64,6 +73,13 @@ export function useTheme(): UseThemeReturn {
[setColorSchemeAction]
);
+ const setCustomHue = useCallback(
+ (hue: number | null) => {
+ setCustomHueAction(hue);
+ },
+ [setCustomHueAction]
+ );
+
const toggleTheme = useCallback(() => {
toggleThemeAction();
}, [toggleThemeAction]);
@@ -73,8 +89,11 @@ export function useTheme(): UseThemeReturn {
resolvedTheme,
isDark: resolvedTheme === 'dark',
colorScheme,
+ customHue,
+ isCustomTheme,
setTheme,
setColorScheme,
+ setCustomHue,
toggleTheme,
};
}
diff --git a/ccw/frontend/src/lib/colorGenerator.test.ts b/ccw/frontend/src/lib/colorGenerator.test.ts
new file mode 100644
index 00000000..7013b2b8
--- /dev/null
+++ b/ccw/frontend/src/lib/colorGenerator.test.ts
@@ -0,0 +1,202 @@
+/**
+ * Unit tests for colorGenerator module
+ * Tests HSL theme generation algorithm and output validation
+ */
+
+import { describe, it, expect } from 'vitest';
+import { generateThemeFromHue, getVariableCount, isValidHue } from './colorGenerator';
+
+describe('colorGenerator', () => {
+ describe('generateThemeFromHue', () => {
+ it('should generate object with 40+ keys for light mode', () => {
+ const result = generateThemeFromHue(180, 'light');
+ const keys = Object.keys(result);
+
+ expect(keys.length).toBeGreaterThanOrEqual(40);
+ });
+
+ it('should generate object with 40+ keys for dark mode', () => {
+ const result = generateThemeFromHue(180, 'dark');
+ const keys = Object.keys(result);
+
+ expect(keys.length).toBeGreaterThanOrEqual(40);
+ });
+
+ it('should return values in "H S% L%" format', () => {
+ const result = generateThemeFromHue(180, 'light');
+ const hslPattern = /^\d+(\.\d+)?\s+\d+(\.\d+)?%\s+\d+(\.\d+)?%$/;
+
+ Object.values(result).forEach(value => {
+ expect(value).toMatch(hslPattern);
+ });
+ });
+
+ it('should generate high lightness values for light mode backgrounds', () => {
+ const result = generateThemeFromHue(180, 'light');
+
+ // Parse lightness from --bg variable
+ const bgLightness = parseInt(result['--bg'].split(' ')[2]);
+ expect(bgLightness).toBeGreaterThan(85);
+
+ // Parse lightness from --surface variable
+ const surfaceLightness = parseInt(result['--surface'].split(' ')[2]);
+ expect(surfaceLightness).toBeGreaterThan(85);
+ });
+
+ it('should generate low lightness values for dark mode backgrounds', () => {
+ const result = generateThemeFromHue(180, 'dark');
+
+ // Parse lightness from --bg variable
+ const bgLightness = parseInt(result['--bg'].split(' ')[2]);
+ expect(bgLightness).toBeLessThan(22);
+
+ // Parse lightness from --surface variable
+ const surfaceLightness = parseInt(result['--surface'].split(' ')[2]);
+ expect(surfaceLightness).toBeLessThan(22);
+ });
+
+ it('should use provided hue in generated variables', () => {
+ const testHue = 240;
+ const result = generateThemeFromHue(testHue, 'light');
+
+ // Check primary hue-based variables
+ const accentHue = parseInt(result['--accent'].split(' ')[0]);
+ expect(accentHue).toBe(testHue);
+
+ const primaryHue = parseInt(result['--primary'].split(' ')[0]);
+ expect(primaryHue).toBe(testHue);
+ });
+
+ it('should handle edge case: hue = 0', () => {
+ const result = generateThemeFromHue(0, 'light');
+
+ expect(Object.keys(result).length).toBeGreaterThanOrEqual(40);
+ expect(result['--accent']).toMatch(/^0\s+\d+%\s+\d+%$/);
+ });
+
+ it('should handle edge case: hue = 360', () => {
+ const result = generateThemeFromHue(360, 'light');
+
+ expect(Object.keys(result).length).toBeGreaterThanOrEqual(40);
+ // 360 should normalize to 0
+ expect(result['--accent']).toMatch(/^0\s+\d+%\s+\d+%$/);
+ });
+
+ it('should handle negative hue values by normalizing', () => {
+ const result = generateThemeFromHue(-60, 'light');
+
+ // -60 should normalize to 300
+ const accentHue = parseInt(result['--accent'].split(' ')[0]);
+ expect(accentHue).toBe(300);
+ });
+
+ it('should handle hue values > 360 by normalizing', () => {
+ const result = generateThemeFromHue(450, 'light');
+
+ // 450 should normalize to 90
+ const accentHue = parseInt(result['--accent'].split(' ')[0]);
+ expect(accentHue).toBe(90);
+ });
+
+ it('should generate different themes for light and dark modes', () => {
+ const lightTheme = generateThemeFromHue(180, 'light');
+ const darkTheme = generateThemeFromHue(180, 'dark');
+
+ // Background should be different
+ expect(lightTheme['--bg']).not.toBe(darkTheme['--bg']);
+
+ // Text should be different
+ expect(lightTheme['--text']).not.toBe(darkTheme['--text']);
+ });
+
+ it('should include all essential CSS variables', () => {
+ const result = generateThemeFromHue(180, 'light');
+
+ const essentialVars = [
+ '--bg',
+ '--surface',
+ '--border',
+ '--text',
+ '--text-secondary',
+ '--accent',
+ '--primary',
+ '--secondary',
+ '--muted',
+ '--success',
+ '--warning',
+ '--error',
+ '--info',
+ '--destructive',
+ '--hover'
+ ];
+
+ essentialVars.forEach(varName => {
+ expect(result).toHaveProperty(varName);
+ });
+ });
+
+ it('should generate complementary secondary colors', () => {
+ const testHue = 180;
+ const result = generateThemeFromHue(testHue, 'light');
+
+ // Secondary should use complementary hue (180 degrees offset)
+ const secondaryHue = parseInt(result['--secondary'].split(' ')[0]);
+ const expectedComplementary = (testHue + 180) % 360;
+ expect(secondaryHue).toBe(expectedComplementary);
+ });
+
+ it('should have consistent variable count across different hues', () => {
+ const hue1 = generateThemeFromHue(0, 'light');
+ const hue2 = generateThemeFromHue(120, 'light');
+ const hue3 = generateThemeFromHue(240, 'light');
+
+ expect(Object.keys(hue1).length).toBe(Object.keys(hue2).length);
+ expect(Object.keys(hue2).length).toBe(Object.keys(hue3).length);
+ });
+
+ it('should have consistent variable count across modes', () => {
+ const lightCount = Object.keys(generateThemeFromHue(180, 'light')).length;
+ const darkCount = Object.keys(generateThemeFromHue(180, 'dark')).length;
+
+ expect(lightCount).toBe(darkCount);
+ });
+ });
+
+ describe('getVariableCount', () => {
+ it('should return count of generated variables', () => {
+ const count = getVariableCount();
+ expect(count).toBeGreaterThanOrEqual(40);
+ });
+
+ it('should return consistent count', () => {
+ const count1 = getVariableCount();
+ const count2 = getVariableCount();
+ expect(count1).toBe(count2);
+ });
+ });
+
+ describe('isValidHue', () => {
+ it('should return true for valid hue values', () => {
+ expect(isValidHue(0)).toBe(true);
+ expect(isValidHue(180)).toBe(true);
+ expect(isValidHue(360)).toBe(true);
+ expect(isValidHue(450)).toBe(true);
+ expect(isValidHue(-60)).toBe(true);
+ });
+
+ it('should return false for NaN', () => {
+ expect(isValidHue(NaN)).toBe(false);
+ });
+
+ it('should return false for Infinity', () => {
+ expect(isValidHue(Infinity)).toBe(false);
+ expect(isValidHue(-Infinity)).toBe(false);
+ });
+
+ it('should return false for non-number types', () => {
+ expect(isValidHue('180' as any)).toBe(false);
+ expect(isValidHue(null as any)).toBe(false);
+ expect(isValidHue(undefined as any)).toBe(false);
+ });
+ });
+});
diff --git a/ccw/frontend/src/lib/colorGenerator.ts b/ccw/frontend/src/lib/colorGenerator.ts
new file mode 100644
index 00000000..2432eae6
--- /dev/null
+++ b/ccw/frontend/src/lib/colorGenerator.ts
@@ -0,0 +1,188 @@
+/**
+ * Color Generator Module
+ * Generates complete CSS variable sets from a single Hue value
+ *
+ * Algorithm based on HSL color space with mode-specific saturation/lightness rules:
+ * - Light mode: Low saturation backgrounds (5-20%), high lightness (85-98%)
+ * - Dark mode: Medium saturation backgrounds (15-30%), low lightness (10-22%)
+ * - Accents: High saturation (60-90%), medium lightness (55-65%) for both modes
+ *
+ * @module colorGenerator
+ */
+
+/**
+ * Generate a complete theme from a single hue value
+ *
+ * @param hue - Hue value from 0 to 360 degrees
+ * @param mode - Theme mode ('light' or 'dark')
+ * @returns Record of CSS variable names to HSL values in 'H S% L%' format
+ *
+ * @example
+ * ```typescript
+ * const theme = generateThemeFromHue(180, 'light');
+ * // Returns: { '--bg': '180 5% 98%', '--accent': '180 70% 60%', ... }
+ * ```
+ */
+export function generateThemeFromHue(
+ hue: number,
+ mode: 'light' | 'dark'
+): Record {
+ // Normalize hue to 0-360 range
+ const normalizedHue = ((hue % 360) + 360) % 360;
+
+ const vars: Record = {};
+
+ if (mode === 'light') {
+ // Light mode: Low saturation, high lightness backgrounds
+ vars['--bg'] = `${normalizedHue} 5% 98%`;
+ vars['--bg-secondary'] = `${normalizedHue} 8% 96%`;
+ vars['--surface'] = `${normalizedHue} 10% 99%`;
+ vars['--surface-hover'] = `${normalizedHue} 12% 97%`;
+ vars['--border'] = `${normalizedHue} 15% 88%`;
+ vars['--border-hover'] = `${normalizedHue} 18% 82%`;
+
+ // Text colors: Low saturation, very low lightness
+ vars['--text'] = `${normalizedHue} 20% 15%`;
+ vars['--text-secondary'] = `${normalizedHue} 10% 45%`;
+ vars['--text-tertiary'] = `${normalizedHue} 8% 60%`;
+ vars['--text-disabled'] = `${normalizedHue} 5% 70%`;
+
+ // Accent colors: High saturation, medium lightness
+ vars['--accent'] = `${normalizedHue} 70% 60%`;
+ vars['--accent-hover'] = `${normalizedHue} 75% 55%`;
+ vars['--accent-active'] = `${normalizedHue} 80% 50%`;
+ vars['--accent-light'] = `${normalizedHue} 65% 90%`;
+ vars['--accent-lighter'] = `${normalizedHue} 60% 95%`;
+
+ // Primary colors
+ vars['--primary'] = `${normalizedHue} 70% 60%`;
+ vars['--primary-hover'] = `${normalizedHue} 75% 55%`;
+ vars['--primary-light'] = `${normalizedHue} 65% 90%`;
+ vars['--primary-lighter'] = `${normalizedHue} 60% 95%`;
+
+ // Secondary colors (complementary hue)
+ const secondaryHue = (normalizedHue + 180) % 360;
+ vars['--secondary'] = `${secondaryHue} 60% 65%`;
+ vars['--secondary-hover'] = `${secondaryHue} 65% 60%`;
+ vars['--secondary-light'] = `${secondaryHue} 55% 90%`;
+
+ // Muted colors
+ vars['--muted'] = `${normalizedHue} 12% 92%`;
+ vars['--muted-hover'] = `${normalizedHue} 15% 88%`;
+ vars['--muted-text'] = `${normalizedHue} 10% 45%`;
+
+ // Semantic colors (success, warning, error, info)
+ vars['--success'] = `120 60% 50%`;
+ vars['--success-light'] = `120 55% 92%`;
+ vars['--success-text'] = `120 70% 35%`;
+
+ vars['--warning'] = `38 90% 55%`;
+ vars['--warning-light'] = `38 85% 92%`;
+ vars['--warning-text'] = `38 95% 40%`;
+
+ vars['--error'] = `0 70% 55%`;
+ vars['--error-light'] = `0 65% 92%`;
+ vars['--error-text'] = `0 75% 40%`;
+
+ vars['--info'] = `200 70% 55%`;
+ vars['--info-light'] = `200 65% 92%`;
+ vars['--info-text'] = `200 75% 40%`;
+
+ // Destructive (danger)
+ vars['--destructive'] = `0 70% 55%`;
+ vars['--destructive-hover'] = `0 75% 50%`;
+ vars['--destructive-light'] = `0 65% 92%`;
+
+ // Interactive states
+ vars['--hover'] = `${normalizedHue} 12% 94%`;
+ vars['--active'] = `${normalizedHue} 15% 90%`;
+ vars['--focus'] = `${normalizedHue} 70% 60%`;
+
+ } else {
+ // Dark mode: Medium saturation, low lightness backgrounds
+ vars['--bg'] = `${normalizedHue} 20% 10%`;
+ vars['--bg-secondary'] = `${normalizedHue} 18% 12%`;
+ vars['--surface'] = `${normalizedHue} 15% 14%`;
+ vars['--surface-hover'] = `${normalizedHue} 18% 16%`;
+ vars['--border'] = `${normalizedHue} 10% 22%`;
+ vars['--border-hover'] = `${normalizedHue} 12% 28%`;
+
+ // Text colors: Low saturation, very high lightness
+ vars['--text'] = `${normalizedHue} 10% 90%`;
+ vars['--text-secondary'] = `${normalizedHue} 8% 65%`;
+ vars['--text-tertiary'] = `${normalizedHue} 6% 50%`;
+ vars['--text-disabled'] = `${normalizedHue} 5% 40%`;
+
+ // Accent colors: High saturation, medium lightness
+ vars['--accent'] = `${normalizedHue} 70% 60%`;
+ vars['--accent-hover'] = `${normalizedHue} 75% 65%`;
+ vars['--accent-active'] = `${normalizedHue} 80% 70%`;
+ vars['--accent-light'] = `${normalizedHue} 60% 25%`;
+ vars['--accent-lighter'] = `${normalizedHue} 55% 20%`;
+
+ // Primary colors
+ vars['--primary'] = `${normalizedHue} 70% 60%`;
+ vars['--primary-hover'] = `${normalizedHue} 75% 65%`;
+ vars['--primary-light'] = `${normalizedHue} 60% 25%`;
+ vars['--primary-lighter'] = `${normalizedHue} 55% 20%`;
+
+ // Secondary colors (complementary hue)
+ const secondaryHue = (normalizedHue + 180) % 360;
+ vars['--secondary'] = `${secondaryHue} 60% 65%`;
+ vars['--secondary-hover'] = `${secondaryHue} 65% 70%`;
+ vars['--secondary-light'] = `${secondaryHue} 55% 25%`;
+
+ // Muted colors
+ vars['--muted'] = `${normalizedHue} 15% 18%`;
+ vars['--muted-hover'] = `${normalizedHue} 18% 22%`;
+ vars['--muted-text'] = `${normalizedHue} 8% 65%`;
+
+ // Semantic colors (success, warning, error, info)
+ vars['--success'] = `120 60% 55%`;
+ vars['--success-light'] = `120 50% 20%`;
+ vars['--success-text'] = `120 65% 65%`;
+
+ vars['--warning'] = `38 90% 60%`;
+ vars['--warning-light'] = `38 80% 22%`;
+ vars['--warning-text'] = `38 95% 70%`;
+
+ vars['--error'] = `0 70% 60%`;
+ vars['--error-light'] = `0 60% 20%`;
+ vars['--error-text'] = `0 75% 70%`;
+
+ vars['--info'] = `200 70% 60%`;
+ vars['--info-light'] = `200 60% 20%`;
+ vars['--info-text'] = `200 75% 70%`;
+
+ // Destructive (danger)
+ vars['--destructive'] = `0 70% 60%`;
+ vars['--destructive-hover'] = `0 75% 65%`;
+ vars['--destructive-light'] = `0 60% 20%`;
+
+ // Interactive states
+ vars['--hover'] = `${normalizedHue} 18% 16%`;
+ vars['--active'] = `${normalizedHue} 20% 20%`;
+ vars['--focus'] = `${normalizedHue} 70% 60%`;
+ }
+
+ return vars;
+}
+
+/**
+ * Get the total count of CSS variables generated
+ * @returns Number of variables in the generated theme
+ */
+export function getVariableCount(): number {
+ // Generate a sample theme to count variables
+ const sample = generateThemeFromHue(0, 'light');
+ return Object.keys(sample).length;
+}
+
+/**
+ * Validate hue value is within acceptable range
+ * @param hue - Hue value to validate
+ * @returns true if valid, false otherwise
+ */
+export function isValidHue(hue: number): boolean {
+ return typeof hue === 'number' && !isNaN(hue) && isFinite(hue);
+}
diff --git a/ccw/frontend/src/locales/en/api-settings.json b/ccw/frontend/src/locales/en/api-settings.json
index a1092be5..6f87f9a5 100644
--- a/ccw/frontend/src/locales/en/api-settings.json
+++ b/ccw/frontend/src/locales/en/api-settings.json
@@ -23,7 +23,9 @@
"multiKeySettings": "Multi-Key Settings",
"syncToCodexLens": "Sync to CodexLens",
"manageModels": "Manage Models",
- "addModel": "Add Model"
+ "addModel": "Add Model",
+ "showDisabled": "Show Disabled",
+ "hideDisabled": "Hide Disabled"
},
"deleteConfirm": "Are you sure you want to delete the provider \"{name}\"?",
"emptyState": {
@@ -129,8 +131,6 @@
"basicInfo": "Basic Information",
"endpointSettings": "Endpoint Settings",
"apiBaseUpdated": "Base URL updated",
- "showDisabled": "Show Disabled",
- "hideDisabled": "Hide Disabled",
"showAll": "Show All",
"saveError": "Failed to save provider",
"deleteError": "Failed to delete provider",
diff --git a/ccw/frontend/src/locales/en/cli-viewer.json b/ccw/frontend/src/locales/en/cli-viewer.json
index 285740b8..2547c520 100644
--- a/ccw/frontend/src/locales/en/cli-viewer.json
+++ b/ccw/frontend/src/locales/en/cli-viewer.json
@@ -47,5 +47,10 @@
"splitHorizontal": "Split Horizontal",
"splitVertical": "Split Vertical",
"closePane": "Close Pane"
- }
+ },
+ "noActiveTab": "No active tab",
+ "selectOrCreate": "Select a tab or start a new CLI execution",
+ "executionNotFound": "Execution not found",
+ "waitingForOutput": "Waiting for output...",
+ "noOutput": "No output"
}
diff --git a/ccw/frontend/src/locales/en/theme.json b/ccw/frontend/src/locales/en/theme.json
index b6e9a989..4dc6683a 100644
--- a/ccw/frontend/src/locales/en/theme.json
+++ b/ccw/frontend/src/locales/en/theme.json
@@ -1,13 +1,15 @@
{
"title": {
"colorScheme": "Color Scheme",
- "themeMode": "Theme Mode"
+ "themeMode": "Theme Mode",
+ "customHue": "Custom Hue"
},
"colorScheme": {
"blue": "Classic Blue",
"green": "Deep Green",
"orange": "Vibrant Orange",
- "purple": "Elegant Purple"
+ "purple": "Elegant Purple",
+ "custom": "Custom"
},
"themeMode": {
"light": "Light",
@@ -17,5 +19,9 @@
"colorScheme": "Select {name} theme",
"themeMode": "Select {name} mode"
},
- "current": "Current theme: {name}"
+ "current": "Current theme: {name}",
+ "hueValue": "Hue: {value}°",
+ "preview": "Preview",
+ "save": "Save Custom Theme",
+ "reset": "Reset to Preset"
}
diff --git a/ccw/frontend/src/locales/zh/api-settings.json b/ccw/frontend/src/locales/zh/api-settings.json
index 017a46cd..3d807d44 100644
--- a/ccw/frontend/src/locales/zh/api-settings.json
+++ b/ccw/frontend/src/locales/zh/api-settings.json
@@ -23,7 +23,9 @@
"multiKeySettings": "多密钥设置",
"syncToCodexLens": "同步到 CodexLens",
"manageModels": "管理模型",
- "addModel": "添加模型"
+ "addModel": "添加模型",
+ "showDisabled": "显示已禁用",
+ "hideDisabled": "隐藏已禁用"
},
"deleteConfirm": "确定要删除提供商 \"{name}\" 吗?",
"emptyState": {
@@ -129,8 +131,6 @@
"basicInfo": "基本信息",
"endpointSettings": "端点设置",
"apiBaseUpdated": "基础 URL 已更新",
- "showDisabled": "显示已禁用",
- "hideDisabled": "隐藏已禁用",
"showAll": "显示全部",
"saveError": "保存提供商失败",
"deleteError": "删除提供商失败",
diff --git a/ccw/frontend/src/locales/zh/cli-viewer.json b/ccw/frontend/src/locales/zh/cli-viewer.json
index 4ca48540..b7a77e30 100644
--- a/ccw/frontend/src/locales/zh/cli-viewer.json
+++ b/ccw/frontend/src/locales/zh/cli-viewer.json
@@ -47,5 +47,10 @@
"splitHorizontal": "水平分割",
"splitVertical": "垂直分割",
"closePane": "关闭窗格"
- }
+ },
+ "noActiveTab": "暂无活动标签页",
+ "selectOrCreate": "选择一个标签页或启动新的 CLI 执行",
+ "executionNotFound": "未找到执行",
+ "waitingForOutput": "等待输出...",
+ "noOutput": "暂无输出"
}
diff --git a/ccw/frontend/src/locales/zh/theme.json b/ccw/frontend/src/locales/zh/theme.json
index 18f8b8f1..a7935b40 100644
--- a/ccw/frontend/src/locales/zh/theme.json
+++ b/ccw/frontend/src/locales/zh/theme.json
@@ -1,13 +1,15 @@
{
"title": {
"colorScheme": "颜色主题",
- "themeMode": "明暗模式"
+ "themeMode": "明暗模式",
+ "customHue": "自定义色调"
},
"colorScheme": {
"blue": "经典蓝",
"green": "深邃绿",
"orange": "活力橙",
- "purple": "优雅紫"
+ "purple": "优雅紫",
+ "custom": "自定义"
},
"themeMode": {
"light": "浅色",
@@ -17,5 +19,9 @@
"colorScheme": "选择{name}主题",
"themeMode": "选择{name}模式"
},
- "current": "当前主题: {name}"
+ "current": "当前主题: {name}",
+ "hueValue": "色调: {value}°",
+ "preview": "预览",
+ "save": "保存自定义主题",
+ "reset": "重置为预设"
}
diff --git a/ccw/frontend/src/pages/CliViewerPage.tsx b/ccw/frontend/src/pages/CliViewerPage.tsx
index d7537356..af79bfef 100644
--- a/ccw/frontend/src/pages/CliViewerPage.tsx
+++ b/ccw/frontend/src/pages/CliViewerPage.tsx
@@ -296,19 +296,35 @@ export function CliViewerPage() {
}
}, [lastMessage, invalidateActive]);
- // Auto-add new executions as tabs when they appear
+ // Auto-add new executions as tabs, distributing across available panes
+ // Uses round-robin distribution to spread executions across panes side-by-side
const addedExecutionsRef = useRef>(new Set());
useEffect(() => {
- if (!focusedPaneId) return;
- for (const executionId of Object.keys(executions)) {
- if (!addedExecutionsRef.current.has(executionId)) {
- addedExecutionsRef.current.add(executionId);
- const exec = executions[executionId];
- const toolShort = exec.tool.split('-')[0];
- addTab(focusedPaneId, executionId, `${toolShort} (${exec.mode})`);
- }
- }
- }, [executions, focusedPaneId, addTab]);
+ // Get all pane IDs from the current layout
+ const paneIds = Object.keys(panes);
+ if (paneIds.length === 0) return;
+
+ // Get addTab from store directly to avoid dependency on reactive function
+ // This prevents infinite loop when addTab updates store state
+ const storeAddTab = useViewerStore.getState().addTab;
+
+ // Get new executions that haven't been added yet
+ const newExecutionIds = Object.keys(executions).filter(
+ (id) => !addedExecutionsRef.current.has(id)
+ );
+
+ if (newExecutionIds.length === 0) return;
+
+ // Distribute new executions across panes round-robin
+ newExecutionIds.forEach((executionId, index) => {
+ addedExecutionsRef.current.add(executionId);
+ const exec = executions[executionId];
+ const toolShort = exec.tool.split('-')[0];
+ // Round-robin pane selection
+ const targetPaneId = paneIds[index % paneIds.length];
+ storeAddTab(targetPaneId, executionId, `${toolShort} (${exec.mode})`);
+ });
+ }, [executions, panes]);
// Initialize layout if empty
useEffect(() => {
diff --git a/ccw/frontend/src/pages/CommandsManagerPage.tsx b/ccw/frontend/src/pages/CommandsManagerPage.tsx
index 0ea5f34b..0f3878d4 100644
--- a/ccw/frontend/src/pages/CommandsManagerPage.tsx
+++ b/ccw/frontend/src/pages/CommandsManagerPage.tsx
@@ -8,19 +8,21 @@ import { useIntl } from 'react-intl';
import {
Terminal,
Search,
- Plus,
RefreshCw,
Eye,
EyeOff,
CheckCircle2,
XCircle,
+ Folder,
+ User,
} from 'lucide-react';
import { Card } from '@/components/ui/Card';
import { Button } from '@/components/ui/Button';
import { Input } from '@/components/ui/Input';
+import { Badge } from '@/components/ui/Badge';
+import { TabsNavigation } from '@/components/ui/TabsNavigation';
import { useCommands, useCommandMutations } from '@/hooks';
import { CommandGroupAccordion } from '@/components/commands/CommandGroupAccordion';
-import { LocationSwitcher } from '@/components/commands/LocationSwitcher';
import { cn } from '@/lib/utils';
// ========== Main Page Component ==========
@@ -113,45 +115,52 @@ export function CommandsManagerPage() {
{formatMessage({ id: 'commands.description' })}
-
-
-
-
+