feat: Implement UI appearance configuration module

This commit introduces a new configuration module for UI appearance settings.
You can now customize various aspects of the application's look and feel.

Key features:
- Customizable color palette: primary, secondary, warning, and background colors.
- Font customization: font family and font size.
- Theme selection: light and dark themes.
- Animation control: toggle animations on/off.

Changes include:
- Extended `ui/src/stores/useUi.js` to manage appearance state, including actions for updates and integration with local storage for persistence.
- Created `ui/src/views/SettingsView.vue` with UI controls for all customizable settings.
- Implemented dynamic application of settings in `ui/src/App.vue` by updating CSS variables and classes on the root element.
- Enhanced `SettingsView.vue` with modern styling (TailwindCSS) and subtle animations, ensuring it's theme-aware and respects animation preferences.
- Added comprehensive unit tests for the `useUi` store and component tests for `SettingsView.vue` using Vitest and Vue Test Utils.

The settings page allows for real-time previews of changes, and all preferences are saved locally to persist across sessions.
This commit is contained in:
google-labs-jules[bot]
2025-05-30 23:17:04 +00:00
parent 4f1ec58a99
commit 56fd503642
9 changed files with 3437 additions and 14 deletions

View File

@@ -0,0 +1,180 @@
import { describe, it, expect, beforeEach, vi } from 'vitest'
import { createPinia, setActivePinia } from 'pinia'
import { useUi } from '../useUi' // Adjust path as necessary
// Mock localStorage
const localStorageMock = (() => {
let store = {}
return {
getItem: vi.fn((key) => store[key] || null),
setItem: vi.fn((key, value) => {
store[key] = value.toString()
}),
clear: vi.fn(() => {
store = {}
}),
removeItem: vi.fn((key) => {
delete store[key]
}),
}
})()
// Define the storage key, matching the one in useUi.js
const APPEARANCE_STORAGE_KEY = 'appearanceSettings';
// Apply the mock to window.localStorage BEFORE store import or usage
vi.stubGlobal('localStorage', localStorageMock)
describe('useUi Store', () => {
beforeEach(() => {
setActivePinia(createPinia())
localStorageMock.clear()
localStorageMock.setItem.mockClear()
localStorageMock.getItem.mockClear()
// Ensure that when the store is initialized, it re-reads from the (mocked) localStorage
// This is important because the store's state definition runs only once when imported.
// For tests, we need to control this. Re-importing or using a factory for useUi might be needed
// if the store is not re-evaluating its state function that calls loadSettingsFromLocalStorage().
// However, Pinia's setup with setActivePinia(createPinia()) should handle store isolation.
})
it('initializes with default appearance settings if no local storage data exists', () => {
const store = useUi()
expect(store.primaryColor).toBe('#1976D2')
expect(store.theme).toBe('light')
expect(store.fontSize).toBe(16)
expect(localStorageMock.getItem).toHaveBeenCalledWith(APPEARANCE_STORAGE_KEY)
})
it('loads settings from localStorage if present', () => {
const storedSettings = {
primaryColor: '#FF0000',
theme: 'dark',
fontSize: 20,
animationsEnabled: false,
// other settings...
}
localStorageMock.getItem.mockReturnValueOnce(JSON.stringify(storedSettings))
const store = useUi()
expect(localStorageMock.getItem).toHaveBeenCalledWith(APPEARANCE_STORAGE_KEY)
expect(store.primaryColor).toBe('#FF0000')
expect(store.theme).toBe('dark')
expect(store.fontSize).toBe(20)
expect(store.animationsEnabled).toBe(false)
})
it('falls back to default settings if localStorage data is invalid JSON', () => {
localStorageMock.getItem.mockReturnValueOnce('invalid json')
const store = useUi()
expect(store.primaryColor).toBe('#1976D2') // Default
})
it('falls back to default settings if localStorage is not available (simulated by load error)', () => {
// Simulate localStorage.getItem throwing an error by making the mock throw
localStorageMock.getItem.mockImplementationOnce(() => {
throw new Error("Storage unavailable");
});
const consoleErrorSpy = vi.spyOn(console, 'error').mockImplementation(() => {}); // Suppress console.error for this test
const store = useUi();
expect(store.primaryColor).toBe('#1976D2'); // Should use default
expect(store.theme).toBe('light');
expect(console.error).toHaveBeenCalledWith('Error loading appearance settings from local storage:', expect.any(Error));
consoleErrorSpy.mockRestore(); // Restore console.error
})
describe('Actions', () => {
const appearanceSettingKeys = [
'primaryColor', 'secondaryColor', 'warningColor', 'fontFamily',
'fontSize', 'animationsEnabled', 'backgroundColor', 'theme',
]
it('setPrimaryColor updates state and saves to localStorage', () => {
const store = useUi()
store.setPrimaryColor('#00FF00')
expect(store.primaryColor).toBe('#00FF00')
expect(localStorageMock.setItem).toHaveBeenCalledWith(
APPEARANCE_STORAGE_KEY,
expect.stringContaining('"primaryColor":"#00FF00"')
)
})
it('setFontSize updates state and saves to localStorage', () => {
const store = useUi()
store.setFontSize(24)
expect(store.fontSize).toBe(24)
expect(localStorageMock.setItem).toHaveBeenCalledWith(
APPEARANCE_STORAGE_KEY,
expect.stringContaining('"fontSize":24')
)
})
it('setAnimationsEnabled updates state and saves to localStorage', () => {
const store = useUi()
store.setAnimationsEnabled(false)
expect(store.animationsEnabled).toBe(false)
expect(localStorageMock.setItem).toHaveBeenCalledWith(
APPEARANCE_STORAGE_KEY,
expect.stringContaining('"animationsEnabled":false')
)
store.setAnimationsEnabled(true)
expect(store.animationsEnabled).toBe(true)
expect(localStorageMock.setItem).toHaveBeenCalledWith(
APPEARANCE_STORAGE_KEY,
expect.stringContaining('"animationsEnabled":true')
)
})
it('setTheme updates state and saves to localStorage', () => {
const store = useUi()
store.setTheme('dark')
expect(store.theme).toBe('dark')
expect(localStorageMock.setItem).toHaveBeenCalledWith(
APPEARANCE_STORAGE_KEY,
expect.stringContaining('"theme":"dark"')
)
})
it('toggleTheme switches theme and saves to localStorage', () => {
const store = useUi() // default is 'light'
store.toggleTheme()
expect(store.theme).toBe('dark')
expect(localStorageMock.setItem).toHaveBeenCalledWith(
APPEARANCE_STORAGE_KEY,
expect.stringContaining('"theme":"dark"')
)
store.toggleTheme()
expect(store.theme).toBe('light')
expect(localStorageMock.setItem).toHaveBeenCalledWith(
APPEARANCE_STORAGE_KEY,
expect.stringContaining('"theme":"light"')
)
})
it('saves only appearance settings to localStorage', () => {
const store = useUi()
// Clear any previous calls from initialization if store was already used in this describe block
localStorageMock.setItem.mockClear();
store.setPrimaryColor('#ABCDEF') // This will trigger a save
// Check if setItem was called
expect(localStorageMock.setItem).toHaveBeenCalledTimes(1);
expect(localStorageMock.setItem).toHaveBeenCalledWith(APPEARANCE_STORAGE_KEY, expect.any(String));
// Now parse the actual saved data
const savedDataString = localStorageMock.setItem.mock.calls[0][1];
const savedData = JSON.parse(savedDataString);
expect(Object.keys(savedData).length).toBe(appearanceSettingKeys.length);
expect(savedData.sidebarOpen).toBeUndefined() // Ensure non-appearance data is not saved
appearanceSettingKeys.forEach(key => {
expect(savedData.hasOwnProperty(key)).toBe(true)
})
})
})
})