feat: enhance theme customization and UI components

- Implemented a new color generation module to create CSS variables based on a single hue value, supporting both light and dark modes.
- Added unit tests for the color generation logic to ensure accuracy and robustness.
- Replaced dropdown location filter with tab navigation in RulesManagerPage and SkillsManagerPage for improved UX.
- Updated app store to manage custom theme hues and states, allowing for dynamic theme adjustments.
- Sanitized notification content before persisting to localStorage to prevent sensitive data exposure.
- Refactored memory retrieval logic to handle archived status more flexibly.
- Improved Tailwind CSS configuration with new gradient utilities and animations.
- Minor adjustments to SettingsPage layout for better visual consistency.
This commit is contained in:
catlog22
2026-02-04 17:20:40 +08:00
parent 88616224e0
commit e260a3f77b
30 changed files with 1377 additions and 388 deletions

View File

@@ -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<AppStore>()(
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<AppStore>()(
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<AppStore>()(
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;

View File

@@ -653,14 +653,14 @@ export const useCoordinatorStore = create<CoordinatorState>()(
{
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<string, unknown>)
isLogPanelExpanded: state.isLogPanelExpanded,
autoScrollLogs: state.autoScrollLogs,
// Only persist basic pipeline info, not full nodes

View File

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