diff --git a/.claude/commands/workflow/review-cycle-fix.md b/.claude/commands/workflow/review-cycle-fix.md index 5a3274e2..0d47aa6a 100644 --- a/.claude/commands/workflow/review-cycle-fix.md +++ b/.claude/commands/workflow/review-cycle-fix.md @@ -532,12 +532,18 @@ Use fix_strategy.test_pattern to run affected tests: ### Error Handling -**Planning Failures**: -- Invalid template → Abort with error message -- Insufficient findings data → Request complete export -- Planning timeout → Retry once, then fail gracefully +**Batching Failures (Phase 1.5)**: +- Invalid findings data → Abort with error message +- Empty batches after grouping → Warn and skip empty batches -**Execution Failures**: +**Planning Failures (Phase 2)**: +- Planning agent timeout → Mark batch as failed, continue with other batches +- Partial plan missing → Skip batch, warn user +- Agent crash → Collect available partial plans, proceed with aggregation +- All agents fail → Abort entire fix session with error +- Aggregation conflicts → Apply conflict resolution (serialize conflicting groups) + +**Execution Failures (Phase 3)**: - Agent crash → Mark group as failed, continue with other groups - Test command not found → Skip test verification, warn user - Git operations fail → Abort with error, preserve state @@ -549,14 +555,34 @@ Use fix_strategy.test_pattern to run affected tests: ### TodoWrite Structure -**Initialization**: +**Initialization (after Phase 1.5 batching)**: ```javascript TodoWrite({ todos: [ - {content: "Phase 1: Discovery & Initialization", status: "completed"}, - {content: "Phase 2: Planning", status: "in_progress"}, - {content: "Phase 3: Execution", status: "pending"}, - {content: "Phase 4: Completion", status: "pending"} + {content: "Phase 1: Discovery & Initialization", status: "completed", activeForm: "Discovering"}, + {content: "Phase 1.5: Intelligent Batching", status: "completed", activeForm: "Batching"}, + {content: "Phase 2: Parallel Planning", status: "in_progress", activeForm: "Planning"}, + {content: " → Batch 1: 4 findings (auth.ts:security)", status: "pending", activeForm: "Planning batch 1"}, + {content: " → Batch 2: 3 findings (query.ts:security)", status: "pending", activeForm: "Planning batch 2"}, + {content: " → Batch 3: 2 findings (config.ts:quality)", status: "pending", activeForm: "Planning batch 3"}, + {content: "Phase 3: Execution", status: "pending", activeForm: "Executing"}, + {content: "Phase 4: Completion", status: "pending", activeForm: "Completing"} + ] +}); +``` + +**During Planning (parallel agents running)**: +```javascript +TodoWrite({ + todos: [ + {content: "Phase 1: Discovery & Initialization", status: "completed", activeForm: "Discovering"}, + {content: "Phase 1.5: Intelligent Batching", status: "completed", activeForm: "Batching"}, + {content: "Phase 2: Parallel Planning", status: "in_progress", activeForm: "Planning"}, + {content: " → Batch 1: 4 findings (auth.ts:security)", status: "completed", activeForm: "Planning batch 1"}, + {content: " → Batch 2: 3 findings (query.ts:security)", status: "in_progress", activeForm: "Planning batch 2"}, + {content: " → Batch 3: 2 findings (config.ts:quality)", status: "in_progress", activeForm: "Planning batch 3"}, + {content: "Phase 3: Execution", status: "pending", activeForm: "Executing"}, + {content: "Phase 4: Completion", status: "pending", activeForm: "Completing"} ] }); ``` @@ -565,23 +591,25 @@ TodoWrite({ ```javascript TodoWrite({ todos: [ - {content: "Phase 1: Discovery & Initialization", status: "completed"}, - {content: "Phase 2: Planning", status: "completed"}, - {content: "Phase 3: Execution", status: "in_progress"}, - {content: " → Stage 1: Parallel execution (3 groups)", status: "completed"}, - {content: " • Group G1: Auth validation (2 findings)", status: "completed"}, - {content: " • Group G2: Query security (3 findings)", status: "completed"}, - {content: " • Group G3: Config quality (1 finding)", status: "completed"}, - {content: " → Stage 2: Serial execution (1 group)", status: "in_progress"}, - {content: " • Group G4: Dependent fixes (2 findings)", status: "in_progress"}, - {content: "Phase 4: Completion", status: "pending"} + {content: "Phase 1: Discovery & Initialization", status: "completed", activeForm: "Discovering"}, + {content: "Phase 1.5: Intelligent Batching", status: "completed", activeForm: "Batching"}, + {content: "Phase 2: Parallel Planning (3 batches → 5 groups)", status: "completed", activeForm: "Planning"}, + {content: "Phase 3: Execution", status: "in_progress", activeForm: "Executing"}, + {content: " → Stage 1: Parallel execution (3 groups)", status: "completed", activeForm: "Executing stage 1"}, + {content: " • Group G1: Auth validation (2 findings)", status: "completed", activeForm: "Fixing G1"}, + {content: " • Group G2: Query security (3 findings)", status: "completed", activeForm: "Fixing G2"}, + {content: " • Group G3: Config quality (1 finding)", status: "completed", activeForm: "Fixing G3"}, + {content: " → Stage 2: Serial execution (1 group)", status: "in_progress", activeForm: "Executing stage 2"}, + {content: " • Group G4: Dependent fixes (2 findings)", status: "in_progress", activeForm: "Fixing G4"}, + {content: "Phase 4: Completion", status: "pending", activeForm: "Completing"} ] }); ``` **Update Rules**: -- Add stage items dynamically based on fix-plan.json timeline -- Add group items per stage +- Add batch items dynamically during Phase 1.5 +- Mark batch items completed as parallel agents return results +- Add stage/group items dynamically after Phase 2 plan aggregation - Mark completed immediately after each group finishes - Update parent phase status when all child items complete @@ -591,12 +619,13 @@ TodoWrite({ ## Best Practices -1. **Trust AI Planning**: Planning agent's grouping and execution strategy are based on dependency analysis -2. **Conservative Approach**: Test verification is mandatory - no fixes kept without passing tests -3. **Parallel Efficiency**: Default 3 concurrent agents balances speed and resource usage -4. **Resume Support**: Fix sessions can resume from checkpoints after interruption -5. **Manual Review**: Always review failed fixes manually - may require architectural changes -6. **Incremental Fixing**: Start with small batches (5-10 findings) before large-scale fixes +1. **Leverage Parallel Planning**: For 10+ findings, parallel batching significantly reduces planning time +2. **Tune Batch Size**: Use `--batch-size` to control granularity (smaller batches = more parallelism, larger = better grouping context) +3. **Conservative Approach**: Test verification is mandatory - no fixes kept without passing tests +4. **Parallel Efficiency**: MAX_PARALLEL=10 for planning agents, 3 concurrent execution agents per stage +5. **Resume Support**: Fix sessions can resume from checkpoints after interruption +6. **Manual Review**: Always review failed fixes manually - may require architectural changes +7. **Incremental Fixing**: Start with small batches (5-10 findings) before large-scale fixes ## Related Commands diff --git a/ccw/frontend/src/components/cli-viewer/TabBar.tsx b/ccw/frontend/src/components/cli-viewer/TabBar.tsx index 77505203..1cc3a620 100644 --- a/ccw/frontend/src/components/cli-viewer/TabBar.tsx +++ b/ccw/frontend/src/components/cli-viewer/TabBar.tsx @@ -1,9 +1,9 @@ // ======================================== // TabBar Component // ======================================== -// Tab management for CLI viewer panes +// Tab management for CLI viewer panes with drag-and-drop support -import { useCallback, useMemo } from 'react'; +import { useCallback, useMemo, useState } from 'react'; import { useIntl } from 'react-intl'; import { X, Pin, PinOff, MoreHorizontal, SplitSquareHorizontal, SplitSquareVertical } from 'lucide-react'; import { cn } from '@/lib/utils'; @@ -14,7 +14,7 @@ import { DropdownMenuItem, DropdownMenuSeparator, DropdownMenuTrigger, -} from '@/components/ui/DropdownMenu'; +} from '@/components/ui/Dropdown'; import { useViewerStore, useViewerPanes, @@ -32,6 +32,7 @@ export interface TabBarProps { interface TabItemProps { tab: TabState; + paneId: PaneId; isActive: boolean; onSelect: () => void; onClose: (e: React.MouseEvent) => void; @@ -49,10 +50,23 @@ const STATUS_COLORS = { // ========== Helper Components ========== +// Data transfer key for tab drag-and-drop +const TAB_DRAG_DATA_TYPE = 'application/x-cli-viewer-tab'; + +interface TabDragData { + tabId: string; + sourcePaneId: string; +} + /** - * Individual tab item + * Individual tab item with drag-and-drop support */ -function TabItem({ tab, isActive, onSelect, onClose, onTogglePin }: TabItemProps) { +function TabItem({ tab, paneId, isActive, onSelect, onClose, onTogglePin }: TabItemProps) { + const [isDragging, setIsDragging] = useState(false); + const [isDragOver, setIsDragOver] = useState(false); + const moveTab = useViewerStore((state) => state.moveTab); + const panes = useViewerPanes(); + // Simplify title for display const displayTitle = useMemo(() => { // If title contains tool name pattern, extract it @@ -60,17 +74,93 @@ function TabItem({ tab, isActive, onSelect, onClose, onTogglePin }: TabItemProps return parts[0] || tab.title; }, [tab.title]); + // Drag start handler + const handleDragStart = useCallback((e: React.DragEvent) => { + const dragData: TabDragData = { + tabId: tab.id, + sourcePaneId: paneId, + }; + e.dataTransfer.setData(TAB_DRAG_DATA_TYPE, JSON.stringify(dragData)); + e.dataTransfer.effectAllowed = 'move'; + setIsDragging(true); + }, [tab.id, paneId]); + + // Drag end handler + const handleDragEnd = useCallback(() => { + setIsDragging(false); + }, []); + + // Drag over handler + const handleDragOver = useCallback((e: React.DragEvent) => { + if (e.dataTransfer.types.includes(TAB_DRAG_DATA_TYPE)) { + e.preventDefault(); + e.dataTransfer.dropEffect = 'move'; + setIsDragOver(true); + } + }, []); + + // Drag leave handler + const handleDragLeave = useCallback(() => { + setIsDragOver(false); + }, []); + + // Drop handler + const handleDrop = useCallback((e: React.DragEvent) => { + e.preventDefault(); + setIsDragOver(false); + + const rawData = e.dataTransfer.getData(TAB_DRAG_DATA_TYPE); + if (!rawData) return; + + try { + const dragData: TabDragData = JSON.parse(rawData); + const { tabId: sourceTabId, sourcePaneId } = dragData; + + // Don't do anything if dropping on the same tab + if (sourceTabId === tab.id) return; + + // Find the target index + const targetPane = panes[paneId]; + if (!targetPane) return; + + const targetIndex = targetPane.tabs.findIndex((t) => t.id === tab.id); + if (targetIndex === -1) return; + + // Move the tab + moveTab(sourcePaneId, sourceTabId, paneId, targetIndex); + } catch (err) { + console.error('[TabBar] Failed to parse drag data:', err); + } + }, [tab.id, paneId, panes, moveTab]); + return ( - )} - + ); } @@ -125,10 +215,12 @@ function TabItem({ tab, isActive, onSelect, onClose, onTogglePin }: TabItemProps * - Active tab highlighting * - Close button on hover * - Pin/unpin functionality + * - Drag-and-drop tab reordering and moving between panes * - Pane actions dropdown */ export function TabBar({ paneId, className }: TabBarProps) { const { formatMessage } = useIntl(); + const [isDragOver, setIsDragOver] = useState(false); const panes = useViewerPanes(); const pane = panes[paneId]; const setActiveTab = useViewerStore((state) => state.setActiveTab); @@ -136,6 +228,7 @@ export function TabBar({ paneId, className }: TabBarProps) { const togglePinTab = useViewerStore((state) => state.togglePinTab); const addPane = useViewerStore((state) => state.addPane); const removePane = useViewerStore((state) => state.removePane); + const moveTab = useViewerStore((state) => state.moveTab); const handleTabSelect = useCallback( (tabId: string) => { @@ -172,6 +265,43 @@ export function TabBar({ paneId, className }: TabBarProps) { removePane(paneId); }, [paneId, removePane]); + // Drag over handler for tab bar container (allows dropping to end of list) + const handleContainerDragOver = useCallback((e: React.DragEvent) => { + if (e.dataTransfer.types.includes(TAB_DRAG_DATA_TYPE)) { + e.preventDefault(); + e.dataTransfer.dropEffect = 'move'; + setIsDragOver(true); + } + }, []); + + // Drag leave handler for container + const handleContainerDragLeave = useCallback((e: React.DragEvent) => { + // Only set false if leaving the container entirely, not just moving to a child + if (!e.currentTarget.contains(e.relatedTarget as Node)) { + setIsDragOver(false); + } + }, []); + + // Drop handler for tab bar container (drops to end of list) + const handleContainerDrop = useCallback((e: React.DragEvent) => { + e.preventDefault(); + setIsDragOver(false); + + const rawData = e.dataTransfer.getData(TAB_DRAG_DATA_TYPE); + if (!rawData) return; + + try { + const dragData: TabDragData = JSON.parse(rawData); + const { tabId: sourceTabId, sourcePaneId } = dragData; + + // Move the tab to the end of this pane + const targetIndex = pane?.tabs.length || 0; + moveTab(sourcePaneId, sourceTabId, paneId, targetIndex); + } catch (err) { + console.error('[TabBar] Failed to parse drag data:', err); + } + }, [paneId, pane, moveTab]); + // Sort tabs: pinned first, then by order const sortedTabs = useMemo(() => { if (!pane) return []; @@ -197,7 +327,15 @@ export function TabBar({ paneId, className }: TabBarProps) { )} > {/* Tabs */} -
+
{sortedTabs.length === 0 ? ( {formatMessage({ id: 'cliViewer.tabs.noTabs', defaultMessage: 'No tabs open' })} @@ -207,6 +345,7 @@ export function TabBar({ paneId, className }: TabBarProps) { 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' })}

-
- - -
+
- {/* Location and Show Disabled Controls */} -
- setLocationFilter(v as 'project' | 'user')} + tabs={[ + { + value: 'project', + label: formatMessage({ id: 'commands.location.project' }), + icon: , + badge: {projectCount}, + disabled: isToggling, + }, + { + value: 'user', + label: formatMessage({ id: 'commands.location.user' }), + icon: , + badge: {userCount}, + disabled: isToggling, + }, + ]} + /> + + {/* Show Disabled Controls */} +
+ -
+ > + {showDisabledCommands ? ( + + ) : ( + + )} + {showDisabledCommands + ? formatMessage({ id: 'commands.actions.hideDisabled' }) + : formatMessage({ id: 'commands.actions.showDisabled' })} + ({disabledCount}) +
diff --git a/ccw/frontend/src/pages/MemoryPage.tsx b/ccw/frontend/src/pages/MemoryPage.tsx index a591a77e..cae2de4d 100644 --- a/ccw/frontend/src/pages/MemoryPage.tsx +++ b/ccw/frontend/src/pages/MemoryPage.tsx @@ -28,6 +28,7 @@ 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 { Dialog, DialogContent, DialogHeader, DialogTitle } from '@/components/ui/Dialog'; import { Checkbox } from '@/components/ui/Checkbox'; import { useMemory, useMemoryMutations } from '@/hooks'; @@ -527,33 +528,28 @@ export function MemoryPage() {
- {/* Tab Navigation */} -
- - - -
+ {/* Tab Navigation - styled like LiteTasksPage */} + setCurrentTab(v as 'memories' | 'favorites' | 'archived')} + tabs={[ + { + value: 'memories', + label: formatMessage({ id: 'memory.tabs.memories' }), + icon: , + }, + { + value: 'favorites', + label: formatMessage({ id: 'memory.tabs.favorites' }), + icon: , + }, + { + value: 'archived', + label: formatMessage({ id: 'memory.tabs.archived' }), + icon: , + }, + ]} + /> {/* Stats Cards */}
diff --git a/ccw/frontend/src/pages/ProjectOverviewPage.tsx b/ccw/frontend/src/pages/ProjectOverviewPage.tsx index 1f30ca8f..df790a78 100644 --- a/ccw/frontend/src/pages/ProjectOverviewPage.tsx +++ b/ccw/frontend/src/pages/ProjectOverviewPage.tsx @@ -235,21 +235,21 @@ export function ProjectOverviewPage() { {/* Header Row */}
-

+

{projectOverview.projectName}

-

+

{projectOverview.description || formatMessage({ id: 'projectOverview.noDescription' })}

-
+
{formatMessage({ id: 'projectOverview.header.initialized' })}:{' '} {formatDate(projectOverview.initializedAt)}
{metadata?.analysis_mode && (
- + {metadata.analysis_mode}
@@ -258,7 +258,7 @@ export function ProjectOverviewPage() {
{/* Technology Stack */} -

+

{formatMessage({ id: 'projectOverview.techStack.title' })}

@@ -266,7 +266,7 @@ export function ProjectOverviewPage() {
{/* Languages */}
-

+

{formatMessage({ id: 'projectOverview.techStack.languages' })}

@@ -274,21 +274,21 @@ export function ProjectOverviewPage() { technologyStack.languages.map((lang: { name: string; file_count: number; primary?: boolean }) => (
{lang.name} - {lang.file_count} + {lang.file_count} {lang.primary && ( - + {formatMessage({ id: 'projectOverview.techStack.primary' })} )}
)) ) : ( - + {formatMessage({ id: 'projectOverview.techStack.noLanguages' })} )} @@ -297,18 +297,18 @@ export function ProjectOverviewPage() { {/* Frameworks */}
-

+

{formatMessage({ id: 'projectOverview.techStack.frameworks' })}

{technologyStack?.frameworks && technologyStack.frameworks.length > 0 ? ( technologyStack.frameworks.map((fw: string) => ( - + {fw} )) ) : ( - + {formatMessage({ id: 'projectOverview.techStack.noFrameworks' })} )} @@ -317,36 +317,36 @@ export function ProjectOverviewPage() { {/* Build Tools */}
-

+

{formatMessage({ id: 'projectOverview.techStack.buildTools' })}

{technologyStack?.build_tools && technologyStack.build_tools.length > 0 ? ( technologyStack.build_tools.map((tool: string) => ( - + {tool} )) ) : ( - - + - )}
{/* Test Frameworks */}
-

+

{formatMessage({ id: 'projectOverview.techStack.testFrameworks' })}

{technologyStack?.test_frameworks && technologyStack.test_frameworks.length > 0 ? ( technologyStack.test_frameworks.map((fw: string) => ( - + {fw} )) ) : ( - - + - )}
@@ -354,120 +354,128 @@ export function ProjectOverviewPage() { - {/* Architecture */} - {architecture && ( + {/* Architecture & Key Components - Merged */} + {(architecture || (keyComponents && keyComponents.length > 0)) && ( -

- - {formatMessage({ id: 'projectOverview.architecture.title' })} -

+ {/* Architecture Section */} + {architecture && ( + <> +

+ + {formatMessage({ id: 'projectOverview.architecture.title' })} +

-
- {/* Style */} -
-

- {formatMessage({ id: 'projectOverview.architecture.style' })} -

-
- {architecture.style} -
-
- - {/* Layers */} - {architecture.layers && architecture.layers.length > 0 && ( -
-

- {formatMessage({ id: 'projectOverview.architecture.layers' })} -

-
- {architecture.layers.map((layer: string) => ( - - {layer} - - ))} -
-
- )} - - {/* Patterns */} - {architecture.patterns && architecture.patterns.length > 0 && ( -
-

- {formatMessage({ id: 'projectOverview.architecture.patterns' })} -

-
- {architecture.patterns.map((pattern: string) => ( - - {pattern} - - ))} -
-
- )} -
-
-
- )} - - {/* Key Components */} - {keyComponents && keyComponents.length > 0 && ( - - -

- - {formatMessage({ id: 'projectOverview.components.title' })} -

- -
- {keyComponents.map((comp: KeyComponent) => { - const importance = comp.importance || 'low'; - const importanceColors: Record = { - high: 'border-l-2 border-l-destructive bg-destructive/5', - medium: 'border-l-2 border-l-warning bg-warning/5', - low: 'border-l-2 border-l-muted-foreground bg-muted', - }; - const importanceBadges: Record = { - high: ( - - {formatMessage({ id: 'projectOverview.components.importance.high' })} - - ), - medium: ( - - {formatMessage({ id: 'projectOverview.components.importance.medium' })} - - ), - low: ( - - {formatMessage({ id: 'projectOverview.components.importance.low' })} - - ), - }; - - return ( -
-
-

{comp.name}

- {importanceBadges[importance]} +
+ {/* Style */} +
+

+ {formatMessage({ id: 'projectOverview.architecture.style' })} +

+
+ {architecture.style}
- {comp.description && ( -

{comp.description}

- )} - {comp.responsibility && comp.responsibility.length > 0 && ( -
    - {comp.responsibility.map((resp: string, i: number) => ( -
  • {resp}
  • - ))} -
- )}
- ); - })} -
+ + {/* Layers */} + {architecture.layers && architecture.layers.length > 0 && ( +
+

+ {formatMessage({ id: 'projectOverview.architecture.layers' })} +

+
+ {architecture.layers.map((layer: string) => ( + + {layer} + + ))} +
+
+ )} + + {/* Patterns */} + {architecture.patterns && architecture.patterns.length > 0 && ( +
+

+ {formatMessage({ id: 'projectOverview.architecture.patterns' })} +

+
+ {architecture.patterns.map((pattern: string) => ( + + {pattern} + + ))} +
+
+ )} +
+ + )} + + {/* Divider between Architecture and Components */} + {architecture && keyComponents && keyComponents.length > 0 && ( +
+ )} + + {/* Key Components Section */} + {keyComponents && keyComponents.length > 0 && ( + <> +

+ + {formatMessage({ id: 'projectOverview.components.title' })} +

+ +
+ {keyComponents.map((comp: KeyComponent) => { + const importance = comp.importance || 'low'; + const importanceColors: Record = { + high: 'border-l-2 border-l-destructive bg-destructive/5', + medium: 'border-l-2 border-l-warning bg-warning/5', + low: 'border-l-2 border-l-muted-foreground bg-muted', + }; + const importanceBadges: Record = { + high: ( + + {formatMessage({ id: 'projectOverview.components.importance.high' })} + + ), + medium: ( + + {formatMessage({ id: 'projectOverview.components.importance.medium' })} + + ), + low: ( + + {formatMessage({ id: 'projectOverview.components.importance.low' })} + + ), + }; + + return ( +
+
+

{comp.name}

+ {importanceBadges[importance]} +
+ {comp.description && ( +

{comp.description}

+ )} + {comp.responsibility && comp.responsibility.length > 0 && ( +
    + {comp.responsibility.map((resp: string, i: number) => ( +
  • {resp}
  • + ))} +
+ )} +
+ ); + })} +
+ + )} )} @@ -477,7 +485,7 @@ export function ProjectOverviewPage() {
-

+

{formatMessage({ id: 'projectOverview.devIndex.title' })}

@@ -487,8 +495,8 @@ export function ProjectOverviewPage() { if (count === 0) return null; const Icon = cat.icon; return ( - - + + {count} ); @@ -498,13 +506,13 @@ export function ProjectOverviewPage() { setDevIndexView(v as DevIndexView)}>
- - - + + + {formatMessage({ id: 'projectOverview.devIndex.categories' })} - - + + {formatMessage({ id: 'projectOverview.devIndex.timeline' })} @@ -519,10 +527,10 @@ export function ProjectOverviewPage() { return (
-

+

{formatMessage({ id: cat.i18nKey })} - {entries.length} + {entries.length}

{entries.slice(0, 5).map((entry: DevelopmentIndexEntry & { type?: string; typeLabel?: string; typeIcon?: React.ElementType; typeColor?: string; date?: string }, i: number) => ( @@ -531,15 +539,15 @@ export function ProjectOverviewPage() { className="p-2 bg-background border border-border rounded hover:shadow-sm transition-shadow" >
-
{entry.title}
- +
{entry.title}
+ {formatDate(entry.archivedAt || entry.date || entry.implemented_at)}
{entry.description && ( -

{entry.description}

+

{entry.description}

)} -
+
{entry.sessionId && ( {entry.sessionId} @@ -563,7 +571,7 @@ export function ProjectOverviewPage() {
))} {entries.length > 5 && ( -
+
... and {entries.length - 5} more
)} @@ -601,20 +609,20 @@ export function ProjectOverviewPage() { ? 'destructive' : 'secondary' } - className="text-[10px] px-1.5 py-0" + className="text-xs px-1.5 py-0" > {entry.typeLabel} -
{entry.title}
+
{entry.title}
- + {formatDate(entry.date)}
{entry.description && ( -

{entry.description}

+

{entry.description}

)} -
+
{entry.sessionId && ( {entry.sessionId} @@ -635,7 +643,7 @@ export function ProjectOverviewPage() { ); })} {allDevEntries.length > 20 && ( -
+
... and {allDevEntries.length - 20} more entries
)} diff --git a/ccw/frontend/src/pages/RulesManagerPage.tsx b/ccw/frontend/src/pages/RulesManagerPage.tsx index b304ec7d..6d2374ec 100644 --- a/ccw/frontend/src/pages/RulesManagerPage.tsx +++ b/ccw/frontend/src/pages/RulesManagerPage.tsx @@ -13,6 +13,9 @@ import { AlertCircle, FileCode, X, + Folder, + User, + Globe, } from 'lucide-react'; import { useRules, @@ -25,6 +28,7 @@ import { RuleDialog } from '@/components/shared/RuleDialog'; 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 { Dialog, DialogContent, @@ -130,6 +134,12 @@ export function RulesManagerPage() { return Array.from(cats).sort(); }, [rules]); + // Count rules by location + const projectRulesCount = React.useMemo(() => + rules.filter((r) => r.location === 'project').length, [rules]); + const userRulesCount = React.useMemo(() => + rules.filter((r) => r.location === 'user').length, [rules]); + // Handlers const handleEditClick = (rule: Rule) => { setSelectedRule(rule); @@ -223,6 +233,35 @@ export function RulesManagerPage() {
)} + {/* Location Tabs - styled like LiteTasksPage */} + setLocationFilter(v as LocationFilter)} + tabs={[ + { + value: 'all', + label: formatMessage({ id: 'rules.filters.all' }), + icon: , + badge: {rules.length}, + disabled: isMutating, + }, + { + value: 'project', + label: formatMessage({ id: 'rules.location.project' }), + icon: , + badge: {projectRulesCount}, + disabled: isMutating, + }, + { + value: 'user', + label: formatMessage({ id: 'rules.location.user' }), + icon: , + badge: {userRulesCount}, + disabled: isMutating, + }, + ]} + /> + {/* Filters */}
{/* Status tabs */} @@ -253,37 +292,6 @@ export function RulesManagerPage() { )}
- {/* Location filter dropdown */} - - - - - - {formatMessage({ id: 'rules.filters.location' })} - - setLocationFilter('all')}> - {formatMessage({ id: 'rules.filters.all' })} - {locationFilter === 'all' && } - - setLocationFilter('project')}> - {formatMessage({ id: 'rules.location.project' })} - {locationFilter === 'project' && } - - setLocationFilter('user')}> - {formatMessage({ id: 'rules.location.user' })} - {locationFilter === 'user' && } - - - - {/* Category filter dropdown */} {categories.length > 0 && ( diff --git a/ccw/frontend/src/pages/SettingsPage.tsx b/ccw/frontend/src/pages/SettingsPage.tsx index 34c3a13f..5c9e5a9b 100644 --- a/ccw/frontend/src/pages/SettingsPage.tsx +++ b/ccw/frontend/src/pages/SettingsPage.tsx @@ -585,7 +585,7 @@ export function SettingsPage() { {/* Display Settings */} - +

{formatMessage({ id: 'settings.sections.display' })} @@ -607,7 +607,7 @@ export function SettingsPage() {

- +
{/* Reset Settings */} diff --git a/ccw/frontend/src/pages/SkillsManagerPage.tsx b/ccw/frontend/src/pages/SkillsManagerPage.tsx index 31cdc86d..17ecb0fa 100644 --- a/ccw/frontend/src/pages/SkillsManagerPage.tsx +++ b/ccw/frontend/src/pages/SkillsManagerPage.tsx @@ -18,10 +18,14 @@ import { EyeOff, List, Grid3x3, + 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 { Select, SelectTrigger, SelectValue, SelectContent, SelectItem } from '@/components/ui/Select'; import { AlertDialog, @@ -34,7 +38,6 @@ import { AlertDialogCancel, } from '@/components/ui'; import { SkillCard, SkillDetailPanel } from '@/components/shared'; -import { LocationSwitcher } from '@/components/commands/LocationSwitcher'; import { useSkills, useSkillMutations } from '@/hooks'; import { fetchSkillDetail } from '@/lib/api'; import { useWorkflowStore, selectProjectPath } from '@/stores/workflowStore'; @@ -245,14 +248,26 @@ export function SkillsManagerPage() {
- {/* Location Switcher */} - setLocationFilter(v as 'project' | 'user')} + tabs={[ + { + value: 'project', + label: formatMessage({ id: 'skills.location.project' }), + icon: , + badge: {projectSkills.length}, + disabled: isToggling, + }, + { + value: 'user', + label: formatMessage({ id: 'skills.location.user' }), + icon: , + badge: {userSkills.length}, + disabled: isToggling, + }, + ]} />
diff --git a/ccw/frontend/src/stores/appStore.ts b/ccw/frontend/src/stores/appStore.ts index 142625b0..12d54d11 100644 --- a/ccw/frontend/src/stores/appStore.ts +++ b/ccw/frontend/src/stores/appStore.ts @@ -9,6 +9,7 @@ import type { AppStore, Theme, ColorScheme, Locale, ViewMode, SessionFilter, Lit import { DEFAULT_DASHBOARD_LAYOUT } from '../components/dashboard/defaultLayouts'; import { getInitialLocale, updateIntl } from '../lib/i18n'; import { getThemeId } from '../lib/theme'; +import { generateThemeFromHue } from '../lib/colorGenerator'; // Helper to resolve system theme const getSystemTheme = (): 'light' | 'dark' => { @@ -24,12 +25,87 @@ const resolveTheme = (theme: Theme): 'light' | 'dark' => { return theme; }; +/** + * DOM Theme Application Helper + * + * ARCHITECTURAL NOTE: This function contains DOM manipulation logic that ideally + * belongs in a React component/hook rather than a store. However, it's placed + * here for pragmatic reasons: + * - Immediate theme application without React render cycle + * - SSR compatibility (checks for document/window) + * - Backward compatibility with existing codebase + * + * FUTURE IMPROVEMENT: Move theme application to a ThemeProvider component using + * useEffect to listen for store changes. This would properly separate concerns. + */ +const applyThemeToDocument = ( + resolvedTheme: 'light' | 'dark', + colorScheme: ColorScheme, + customHue: number | null +): void => { + if (typeof document === 'undefined') return; + + // Define the actual DOM update logic + const performThemeUpdate = () => { + // Update document classes + document.documentElement.classList.remove('light', 'dark'); + document.documentElement.classList.add(resolvedTheme); + + // Clear custom CSS variables list + const customVars = [ + '--bg', '--bg-secondary', '--surface', '--surface-hover', + '--border', '--border-hover', '--text', '--text-secondary', + '--text-tertiary', '--text-disabled', '--accent', '--accent-hover', + '--accent-active', '--accent-light', '--accent-lighter', '--primary', + '--primary-hover', '--primary-light', '--primary-lighter', '--secondary', + '--secondary-hover', '--secondary-light', '--muted', '--muted-hover', + '--muted-text', '--success', '--success-light', '--success-text', + '--warning', '--warning-light', '--warning-text', '--error', + '--error-light', '--error-text', '--info', '--info-light', + '--info-text', '--destructive', '--destructive-hover', '--destructive-light', + '--hover', '--active', '--focus' + ]; + + // Apply custom theme or preset theme + if (customHue !== null) { + const cssVars = generateThemeFromHue(customHue, resolvedTheme); + Object.entries(cssVars).forEach(([varName, varValue]) => { + document.documentElement.style.setProperty(varName, varValue); + }); + document.documentElement.setAttribute('data-theme', `custom-${resolvedTheme}`); + } else { + // Clear custom CSS variables + customVars.forEach(varName => { + document.documentElement.style.removeProperty(varName); + }); + // Apply preset theme + const themeId = getThemeId(colorScheme, resolvedTheme); + document.documentElement.setAttribute('data-theme', themeId); + } + + // Set color scheme attribute + document.documentElement.setAttribute('data-color-scheme', colorScheme); + }; + + // Use View Transition API for smooth transitions (progressive enhancement) + // @ts-expect-error - View Transition API not yet in TypeScript DOM types + if (document.startViewTransition) { + // @ts-expect-error - View Transition API not yet in TypeScript DOM types + document.startViewTransition(performThemeUpdate); + } else { + // Fallback: apply immediately without transition + performThemeUpdate(); + } +}; + // Initial state const initialState = { // Theme theme: 'system' as Theme, resolvedTheme: 'light' as 'light' | 'dark', colorScheme: 'blue' as ColorScheme, // New: default to blue scheme + customHue: null as number | null, + isCustomTheme: false, // Locale locale: getInitialLocale() as Locale, @@ -66,26 +142,32 @@ export const useAppStore = create()( const resolved = resolveTheme(theme); set({ theme, resolvedTheme: resolved }, false, 'setTheme'); - // Apply theme to document - if (typeof document !== 'undefined') { - const { colorScheme } = get(); - const themeId = getThemeId(colorScheme, resolved); - document.documentElement.classList.remove('light', 'dark'); - document.documentElement.classList.add(resolved); - document.documentElement.setAttribute('data-theme', themeId); - } + // Apply theme using helper (encapsulates DOM manipulation) + const { colorScheme, customHue } = get(); + applyThemeToDocument(resolved, colorScheme, customHue); }, setColorScheme: (colorScheme: ColorScheme) => { - set({ colorScheme }, false, 'setColorScheme'); + set({ colorScheme, customHue: null, isCustomTheme: false }, false, 'setColorScheme'); - // Apply color scheme to document - if (typeof document !== 'undefined') { - const { resolvedTheme } = get(); - const themeId = getThemeId(colorScheme, resolvedTheme); - document.documentElement.setAttribute('data-theme', themeId); - document.documentElement.setAttribute('data-color-scheme', colorScheme); + // Apply color scheme using helper (encapsulates DOM manipulation) + const { resolvedTheme } = get(); + applyThemeToDocument(resolvedTheme, colorScheme, null); + }, + + setCustomHue: (hue: number | null) => { + if (hue === null) { + // Reset to preset theme + const { colorScheme, resolvedTheme } = get(); + set({ customHue: null, isCustomTheme: false }, false, 'setCustomHue'); + applyThemeToDocument(resolvedTheme, colorScheme, null); + return; } + + // Apply custom hue + set({ customHue: hue, isCustomTheme: true }, false, 'setCustomHue'); + const { resolvedTheme, colorScheme } = get(); + applyThemeToDocument(resolvedTheme, colorScheme, hue); }, toggleTheme: () => { @@ -189,6 +271,7 @@ export const useAppStore = create()( partialize: (state) => ({ theme: state.theme, colorScheme: state.colorScheme, + customHue: state.customHue, locale: state.locale, sidebarCollapsed: state.sidebarCollapsed, expandedNavGroups: state.expandedNavGroups, @@ -199,12 +282,9 @@ export const useAppStore = create()( if (state) { const resolved = resolveTheme(state.theme); state.resolvedTheme = resolved; - const themeId = getThemeId(state.colorScheme, resolved); - if (typeof document !== 'undefined') { - document.documentElement.classList.remove('light', 'dark'); - document.documentElement.classList.add(resolved); - document.documentElement.setAttribute('data-theme', themeId); - } + state.isCustomTheme = state.customHue !== null; + // Apply theme using helper (encapsulates DOM manipulation) + applyThemeToDocument(resolved, state.colorScheme, state.customHue); } // Apply locale on rehydration if (state) { @@ -225,10 +305,8 @@ if (typeof window !== 'undefined') { if (state.theme === 'system') { const resolved = getSystemTheme(); useAppStore.setState({ resolvedTheme: resolved }); - const themeId = getThemeId(state.colorScheme, resolved); - document.documentElement.classList.remove('light', 'dark'); - document.documentElement.classList.add(resolved); - document.documentElement.setAttribute('data-theme', themeId); + // Apply theme using helper (encapsulates DOM manipulation) + applyThemeToDocument(resolved, state.colorScheme, state.customHue); } }); } @@ -236,6 +314,9 @@ if (typeof window !== 'undefined') { // Selectors for common access patterns export const selectTheme = (state: AppStore) => state.theme; export const selectResolvedTheme = (state: AppStore) => state.resolvedTheme; +export const selectColorScheme = (state: AppStore) => state.colorScheme; +export const selectCustomHue = (state: AppStore) => state.customHue; +export const selectIsCustomTheme = (state: AppStore) => state.isCustomTheme; export const selectLocale = (state: AppStore) => state.locale; export const selectSidebarOpen = (state: AppStore) => state.sidebarOpen; export const selectCurrentView = (state: AppStore) => state.currentView; diff --git a/ccw/frontend/src/stores/coordinatorStore.ts b/ccw/frontend/src/stores/coordinatorStore.ts index c3bf4958..e4d8098f 100644 --- a/ccw/frontend/src/stores/coordinatorStore.ts +++ b/ccw/frontend/src/stores/coordinatorStore.ts @@ -653,14 +653,14 @@ export const useCoordinatorStore = create()( { name: LOG_STORAGE_KEY, version: COORDINATOR_STORAGE_VERSION, - // Only persist metadata and basic pipeline info (not full nodes/logs) + // Only persist basic pipeline info (not full nodes/logs or metadata which may contain sensitive data) partialize: (state) => ({ currentExecutionId: state.currentExecutionId, status: state.status, startedAt: state.startedAt, completedAt: state.completedAt, totalElapsedMs: state.totalElapsedMs, - metadata: state.metadata, + // Exclude metadata from persistence - it may contain sensitive data (Record) isLogPanelExpanded: state.isLogPanelExpanded, autoScrollLogs: state.autoScrollLogs, // Only persist basic pipeline info, not full nodes diff --git a/ccw/frontend/src/stores/notificationStore.ts b/ccw/frontend/src/stores/notificationStore.ts index 632665e3..433ee4c8 100644 --- a/ccw/frontend/src/stores/notificationStore.ts +++ b/ccw/frontend/src/stores/notificationStore.ts @@ -21,9 +21,52 @@ const NOTIFICATION_STORAGE_KEY = 'ccw_notifications'; const NOTIFICATION_MAX_STORED = 100; const NOTIFICATION_MAX_AGE_MS = 7 * 24 * 60 * 60 * 1000; // 7 days +// Patterns that should not be stored in localStorage (potential sensitive data) +const SENSITIVE_PATTERNS = [ + // API keys and tokens (common formats) + /\b[A-Za-z0-9_-]{20,}\b/g, + // UUIDs (might be session tokens) + /\b[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}\b/gi, + // Base64 encoded strings (might be tokens) + /\b[A-Za-z0-9+/=]{32,}={0,2}\b/g, +]; + +/** + * Sanitize notification content before persisting to localStorage + * Removes potentially sensitive patterns and limits content length + */ +const sanitizeNotification = (toast: Toast): Toast => { + const sanitizeText = (text: string | null | undefined): string | null => { + if (!text) return null; + let sanitized = text; + + // Remove potentially sensitive patterns + for (const pattern of SENSITIVE_PATTERNS) { + sanitized = sanitized.replace(pattern, '[REDACTED]'); + } + + // Limit length to prevent localStorage bloat + const MAX_LENGTH = 500; + if (sanitized.length > MAX_LENGTH) { + sanitized = sanitized.substring(0, MAX_LENGTH) + '...'; + } + + return sanitized; + }; + + return { + ...toast, + title: sanitizeText(toast.title) || toast.title, + message: sanitizeText(toast.message) || toast.message, + // Don't persist a2uiSurface or a2uiState as they may contain sensitive runtime data + a2uiSurface: undefined, + a2uiState: undefined, + }; +}; + // Helper to generate unique ID const generateId = (): string => { - return `toast-${Date.now()}-${Math.random().toString(36).substr(2, 9)}`; + return `toast-${Date.now()}-${Math.random().toString(36).substring(2, 11)}`; }; // Helper to load notifications from localStorage @@ -51,7 +94,9 @@ const saveToStorage = (notifications: Toast[]): void => { try { // Keep only the last N notifications const toSave = notifications.slice(0, NOTIFICATION_MAX_STORED); - localStorage.setItem(NOTIFICATION_STORAGE_KEY, JSON.stringify(toSave)); + // Sanitize notification content before persisting to localStorage + const sanitized = toSave.map(sanitizeNotification); + localStorage.setItem(NOTIFICATION_STORAGE_KEY, JSON.stringify(sanitized)); } catch (e) { console.error('[NotificationStore] Failed to save to storage:', e); } diff --git a/ccw/frontend/src/types/store.ts b/ccw/frontend/src/types/store.ts index d988d5d7..1c16b339 100644 --- a/ccw/frontend/src/types/store.ts +++ b/ccw/frontend/src/types/store.ts @@ -39,6 +39,8 @@ export interface AppState { theme: Theme; resolvedTheme: 'light' | 'dark'; colorScheme: ColorScheme; // New: 4 color scheme options (blue/green/orange/purple) + customHue: number | null; // Custom hue value (0-360) for theme customization + isCustomTheme: boolean; // Indicates if custom theme is active // Locale locale: Locale; @@ -68,6 +70,7 @@ export interface AppActions { setTheme: (theme: Theme) => void; toggleTheme: () => void; setColorScheme: (scheme: ColorScheme) => void; // New: set color scheme + setCustomHue: (hue: number | null) => void; // Set custom hue for theme customization // Locale actions setLocale: (locale: Locale) => void; diff --git a/ccw/frontend/tailwind.config.js b/ccw/frontend/tailwind.config.js index eeb3c7d6..6f598ec7 100644 --- a/ccw/frontend/tailwind.config.js +++ b/ccw/frontend/tailwind.config.js @@ -1,4 +1,46 @@ /** @type {import('tailwindcss').Config} */ +import plugin from 'tailwindcss/plugin'; + +// Gradient utilities plugin +const gradientPlugin = plugin(function({ addUtilities, addComponents }) { + // 1. Background gradient utilities + addUtilities({ + '.bg-gradient-primary': { + backgroundImage: 'radial-gradient(circle, hsl(var(--accent)) 0%, transparent 70%)', + }, + '.bg-gradient-brand': { + backgroundImage: 'linear-gradient(to right, hsl(var(--primary)), hsl(var(--secondary)))', + }, + '.bg-gradient-radial': { + backgroundImage: 'radial-gradient(var(--tw-gradient-stops))', + }, + '.bg-gradient-conic': { + backgroundImage: 'conic-gradient(var(--tw-gradient-stops))', + }, + }); + + // 2. Gradient border component + addComponents({ + '.border-gradient-brand': { + position: 'relative', + zIndex: '0', + '&::before': { + content: '""', + position: 'absolute', + inset: '0', + zIndex: '-1', + borderRadius: 'inherit', + padding: '1px', + background: 'linear-gradient(to right, hsl(var(--primary)), hsl(var(--secondary)))', + '-webkit-mask': 'linear-gradient(#fff 0 0) content-box, linear-gradient(#fff 0 0)', + 'mask': 'linear-gradient(#fff 0 0) content-box, linear-gradient(#fff 0 0)', + '-webkit-mask-composite': 'xor', + 'mask-composite': 'exclude', + }, + }, + }); +}); + export default { darkMode: ['class', '[data-theme="dark"]'], content: [ @@ -88,6 +130,7 @@ export default { DEFAULT: "0 2px 8px rgb(0 0 0 / 0.08)", md: "0 4px 12px rgb(0 0 0 / 0.1)", lg: "0 8px 24px rgb(0 0 0 / 0.12)", + "glow-accent": "0 0 40px 10px hsl(var(--accent) / 0.7)", }, borderRadius: { @@ -109,14 +152,22 @@ export default { "0%": { transform: "translateX(0)" }, "100%": { transform: "translateX(-50%)" }, }, + "slow-gradient-shift": { + "0%": { backgroundImage: "linear-gradient(135deg, hsl(var(--primary)) 0%, hsl(var(--secondary)) 100%)" }, + "25%": { backgroundImage: "linear-gradient(135deg, hsl(var(--secondary)) 0%, hsl(var(--accent)) 100%)" }, + "50%": { backgroundImage: "linear-gradient(135deg, hsl(var(--accent)) 0%, hsl(var(--primary)) 100%)" }, + "75%": { backgroundImage: "linear-gradient(135deg, hsl(var(--primary)) 0%, hsl(var(--secondary)) 100%)" }, + "100%": { backgroundImage: "linear-gradient(135deg, hsl(var(--primary)) 0%, hsl(var(--secondary)) 100%)" }, + }, }, animation: { "accordion-down": "accordion-down 0.2s ease-out", "accordion-up": "accordion-up 0.2s ease-out", marquee: "marquee 30s linear infinite", + "slow-gradient": "slow-gradient-shift 60s ease-in-out infinite alternate", }, }, }, - plugins: [require("tailwindcss-animate")], + plugins: [require("tailwindcss-animate"), gradientPlugin], } diff --git a/ccw/src/commands/stop.ts b/ccw/src/commands/stop.ts index 9256f43c..928022bf 100644 --- a/ccw/src/commands/stop.ts +++ b/ccw/src/commands/stop.ts @@ -54,7 +54,7 @@ async function killProcess(pid: string): Promise { * @param {Object} options - Command options */ export async function stopCommand(options: StopOptions): Promise { - const port = options.port || 3456; + const port = Number(options.port) || 3456; const reactPort = port + 1; // React frontend runs on port + 1 const force = options.force || false; diff --git a/ccw/src/core/core-memory-store.ts b/ccw/src/core/core-memory-store.ts index 46b95a05..c775d753 100644 --- a/ccw/src/core/core-memory-store.ts +++ b/ccw/src/core/core-memory-store.ts @@ -381,16 +381,29 @@ export class CoreMemoryStore { * Get all memories */ getMemories(options: { archived?: boolean; limit?: number; offset?: number } = {}): CoreMemory[] { - const { archived = false, limit = 50, offset = 0 } = options; + const { archived, limit = 50, offset = 0 } = options; - const stmt = this.db.prepare(` - SELECT * FROM memories - WHERE archived = ? - ORDER BY updated_at DESC - LIMIT ? OFFSET ? - `); + let stmt; + let rows; - const rows = stmt.all(archived ? 1 : 0, limit, offset) as any[]; + if (archived === undefined) { + // Fetch all memories regardless of archived status + stmt = this.db.prepare(` + SELECT * FROM memories + ORDER BY updated_at DESC + LIMIT ? OFFSET ? + `); + rows = stmt.all(limit, offset) as any[]; + } else { + // Fetch memories filtered by archived status + stmt = this.db.prepare(` + SELECT * FROM memories + WHERE archived = ? + ORDER BY updated_at DESC + LIMIT ? OFFSET ? + `); + rows = stmt.all(archived ? 1 : 0, limit, offset) as any[]; + } return rows.map(row => ({ id: row.id, content: row.content, diff --git a/ccw/src/core/routes/core-memory-routes.ts b/ccw/src/core/routes/core-memory-routes.ts index 4d907bcf..51243e38 100644 --- a/ccw/src/core/routes/core-memory-routes.ts +++ b/ccw/src/core/routes/core-memory-routes.ts @@ -31,8 +31,10 @@ export async function handleCoreMemoryRoutes(ctx: RouteContext): Promise