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

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

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