mirror of
https://github.com/catlog22/Claude-Code-Workflow.git
synced 2026-02-28 09:23:08 +08:00
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:
202
ccw/frontend/src/lib/colorGenerator.test.ts
Normal file
202
ccw/frontend/src/lib/colorGenerator.test.ts
Normal file
@@ -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);
|
||||
});
|
||||
});
|
||||
});
|
||||
188
ccw/frontend/src/lib/colorGenerator.ts
Normal file
188
ccw/frontend/src/lib/colorGenerator.ts
Normal file
@@ -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<string, string> {
|
||||
// Normalize hue to 0-360 range
|
||||
const normalizedHue = ((hue % 360) + 360) % 360;
|
||||
|
||||
const vars: Record<string, string> = {};
|
||||
|
||||
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);
|
||||
}
|
||||
Reference in New Issue
Block a user