diff --git a/frontend/js/themes.js b/frontend/js/themes.js index 42ea30b..3874ec1 100644 --- a/frontend/js/themes.js +++ b/frontend/js/themes.js @@ -526,26 +526,85 @@ var THEMES = { var _currentTheme = 'defaut-obsigate'; var _currentMode = 'dark'; +var AVAILABLE_MODES = ['dark', 'light', 'high-contrast', 'sepia']; + +// ── Mode generators ───────────────────────────────────────────────── +// Generate a high-contrast variant from dark-mode CSS vars. +function _genHighContrast(darkVars) { + return { + '--bg-primary': '#000000', '--bg-secondary': '#0a0a0a', '--bg-sidebar': '#000000', + '--bg-hover': '#141414', '--border': '#444444', '--text-primary': '#ffffff', + '--text-secondary': '#cccccc', '--text-muted': '#888888', '--accent': darkVars['--accent'] || '#58a6ff', + '--accent-green': '#4cff6e', '--tag-bg': '#1a2a44', '--tag-text': '#7dc4ff', + '--code-bg': '#0a0a0a', '--search-bg': '#111111', '--scrollbar': '#444444', + '--resize-handle': '#444444', '--overlay-bg': 'rgba(0,0,0,0.85)', + '--danger': '#ff6b6b', '--danger-bg': '#4a0f0f', '--success': '#4cff6e', '--success-bg': '#0a3a14', + '--accent-card': darkVars['--accent'] || '#58a6ff', '--accent-bg': '#1a2a44', '--accent-text': '#7dc4ff', + '--green': '#4cff6e', '--green-bg': '#0a3a14', '--purple': '#c084fc', '--purple-bg': '#2a1a4a', + '--surface': '#0a0a0a', '--surface2': '#000000', '--surface3': '#000000', + '--border-md': '#444444', '--text': '#ffffff', '--text-2': '#cccccc', '--text-3': '#888888', + '--ok': '#4cff6e', '--warn': '#ffcc00', '--ai': '#c084fc', '--err': '#ff6b6b' + }; +} + +// Generate a sepia variant from light-mode CSS vars. +function _genSepia(lightVars) { + return { + '--bg-primary': '#f5ecd5', '--bg-secondary': '#eee3c8', '--bg-sidebar': '#e8dcc0', + '--bg-hover': '#e0d4b4', '--border': '#c8b890', '--text-primary': '#3d2e1a', + '--text-secondary': '#6b5a3e', '--text-muted': '#9a8a6a', '--accent': '#b8860b', + '--accent-green': '#5a8a32', '--tag-bg': '#e8dcc0', '--tag-text': '#8b6914', + '--code-bg': '#eee3c8', '--search-bg': '#f5ecd5', '--scrollbar': '#c8b890', + '--resize-handle': '#c8b890', '--overlay-bg': 'rgba(60,40,10,0.25)', + '--danger': '#b33a2a', '--danger-bg': '#f5e0dc', '--success': '#5a8a32', '--success-bg': '#e0edd0', + '--accent-card': '#b8860b', '--accent-bg': '#f0e4c4', '--accent-text': '#8b6914', + '--green': '#5a8a32', '--green-bg': '#e0edd0', '--purple': '#7a5a9a', '--purple-bg': '#e8ddf0', + '--surface': '#eee3c8', '--surface2': '#f5ecd5', '--surface3': '#e8dcc0', + '--border-md': '#c8b890', '--text': '#3d2e1a', '--text-2': '#6b5a3e', '--text-3': '#9a8a6a', + '--ok': '#5a8a32', '--warn': '#b8860b', '--ai': '#7a5a9a', '--err': '#b33a2a' + }; +} + +// Get CSS vars for a theme + mode (generating HC/sepia on demand). +function _getVars(themeKey, mode) { + var theme = THEMES[themeKey]; + if (!theme) return null; + if (theme.modes[mode]) return theme.modes[mode]; + // Generate high-contrast / sepia from dark / light + if (mode === 'high-contrast') return _genHighContrast(theme.modes.dark || {}); + if (mode === 'sepia') return _genSepia(theme.modes.light || {}); + return null; +} + function applyTheme(themeKey, mode) { var theme = THEMES[themeKey]; if (!theme) return; mode = mode || _currentMode || 'dark'; - var vars = theme.modes[mode]; + var vars = _getVars(themeKey, mode); if (!vars) return; var root = document.documentElement; Object.keys(vars).forEach(function(key) { root.style.setProperty(key, vars[key]); }); + // Store resolved vars on the theme for export/preview + if (!theme.modes[mode]) theme.modes[mode] = vars; _currentTheme = themeKey; _currentMode = mode; try { localStorage.setItem('obsigate-theme', themeKey); } catch(e) {} try { localStorage.setItem('obsigate-theme-mode', mode); } catch(e) {} + + // Notify other components of theme change + try { + root.setAttribute('data-theme', mode); + document.dispatchEvent(new CustomEvent('themechange', { detail: { theme: themeKey, mode: mode } })); + } catch(e) {} } function toggleThemeMode() { - var newMode = _currentMode === 'dark' ? 'light' : 'dark'; + var idx = AVAILABLE_MODES.indexOf(_currentMode); + var newMode = AVAILABLE_MODES[(idx + 1) % AVAILABLE_MODES.length]; applyTheme(_currentTheme, newMode); return newMode; } @@ -553,14 +612,131 @@ function toggleThemeMode() { function getCurrentTheme() { return _currentTheme; } function getCurrentMode() { return _currentMode; } +// ── Public API ────────────────────────────────────────────────────── +function setTheme(name) { + if (THEMES[name]) { + applyTheme(name, _currentMode); + } +} + +function getTheme() { + return _currentTheme; +} + +function listThemes() { + return Object.keys(THEMES).map(function(key) { + return { id: key, name: THEMES[key].name, desc: THEMES[key].desc }; + }); +} + +function listModes() { + return AVAILABLE_MODES.slice(); +} + +// ── Import / Export ───────────────────────────────────────────────── +function exportTheme(themeKey) { + themeKey = themeKey || _currentTheme; + var theme = THEMES[themeKey]; + if (!theme) return null; + // Ensure HC/sepia are resolved for export + var modes = {}; + AVAILABLE_MODES.forEach(function(m) { + modes[m] = _getVars(themeKey, m); + }); + return JSON.stringify({ + version: 1, + id: themeKey, + name: theme.name, + desc: theme.desc, + modes: modes + }, null, 2); +} + +function exportAllThemes() { + var out = {}; + Object.keys(THEMES).forEach(function(key) { + out[key] = { + name: THEMES[key].name, + desc: THEMES[key].desc, + modes: {} + }; + AVAILABLE_MODES.forEach(function(m) { + out[key].modes[m] = _getVars(key, m); + }); + }); + return JSON.stringify({ version: 1, themes: out }, null, 2); +} + +function importTheme(jsonStr) { + try { + var data = JSON.parse(jsonStr); + if (!data || !data.modes) return { ok: false, error: 'Invalid theme JSON: missing modes' }; + + var name = data.name || data.id || ('custom-' + Date.now()); + var key = (data.id || name).toLowerCase().replace(/[^a-z0-9]+/g, '-'); + + var modes = {}; + if (data.modes.dark && typeof data.modes.dark === 'object') modes.dark = data.modes.dark; + if (data.modes.light && typeof data.modes.light === 'object') modes.light = data.modes.light; + if (Object.keys(modes).length === 0) return { ok: false, error: 'Theme must define at least dark or light mode' }; + + THEMES[key] = { name: name, desc: data.desc || 'Custom theme', modes: modes }; + + try { localStorage.setItem('obsigate-custom-' + key, JSON.stringify(THEMES[key])); } catch(e) {} + return { ok: true, key: key }; + } catch(e) { + return { ok: false, error: 'JSON parse error: ' + e.message }; + } +} + +function loadCustomThemes() { + var keys = []; + try { + for (var i = 0; i < localStorage.length; i++) { + var k = localStorage.key(i); + if (k && k.indexOf('obsigate-custom-') === 0) { + keys.push(k); + } + } + } catch(e) {} + keys.forEach(function(lsKey) { + try { + var data = JSON.parse(localStorage.getItem(lsKey)); + if (data && data.modes) { + var key = lsKey.replace('obsigate-custom-', ''); + THEMES[key] = data; + } + } catch(e) {} + }); +} + +function deleteCustomTheme(key) { + if (THEMES[key] && THEMES[key].desc === 'Custom theme') { + delete THEMES[key]; + try { localStorage.removeItem('obsigate-custom-' + key); } catch(e) {} + return true; + } + return false; +} + function initThemes() { + // Load any custom themes from localStorage first + loadCustomThemes(); var saved = null; var savedMode = null; try { saved = localStorage.getItem('obsigate-theme'); } catch(e) {} try { savedMode = localStorage.getItem('obsigate-theme-mode'); } catch(e) {} if (saved && THEMES[saved]) _currentTheme = saved; - if (savedMode) _currentMode = savedMode; + if (savedMode && AVAILABLE_MODES.indexOf(savedMode) !== -1) _currentMode = savedMode; applyTheme(_currentTheme, _currentMode); } -export { THEMES, applyTheme, toggleThemeMode, getCurrentTheme, getCurrentMode, initThemes }; +export { + THEMES, AVAILABLE_MODES, + applyTheme, toggleThemeMode, + getCurrentTheme, getCurrentMode, + getTheme, + setTheme, listThemes, listModes, + exportTheme, exportAllThemes, importTheme, deleteCustomTheme, loadCustomThemes, + initThemes +}; diff --git a/frontend/js/ui.js b/frontend/js/ui.js index 958b209..9c31ba6 100644 --- a/frontend/js/ui.js +++ b/frontend/js/ui.js @@ -177,14 +177,16 @@ export function toggleTheme() { // Update the theme toggle label const themeLabel = document.getElementById("theme-label"); if (themeLabel) { - themeLabel.textContent = newMode === "dark" ? t('theme.dark') : t('theme.light'); + const modeLabelMap = { dark: 'theme.dark', light: 'theme.light', 'high-contrast': 'theme.high_contrast', sepia: 'theme.sepia' }; + themeLabel.textContent = t(modeLabelMap[newMode] || 'theme.dark'); } // Update the theme toggle icon const themeBtn = document.getElementById("theme-toggle"); if (themeBtn) { const icon = themeBtn.querySelector("i"); if (icon) { - icon.setAttribute("data-lucide", newMode === "dark" ? "moon" : "sun"); + const isLight = newMode === "light" || newMode === "sepia"; + icon.setAttribute("data-lucide", isLight ? "sun" : "moon"); } } safeCreateIcons(); diff --git a/tests/test_themes.py b/tests/test_themes.py new file mode 100644 index 0000000..65e1b7d --- /dev/null +++ b/tests/test_themes.py @@ -0,0 +1,208 @@ +"""Tests for the Custom Themes feature (#65). + +Validates: +- Theme JSON structure validity +- All 15 built-in themes have required keys and valid CSS colors +- High-contrast and sepia mode generators produce valid CSS vars +- Import/export round-trip +- AVAILABLE_MODES includes all 4 modes +""" +import json +import re +import pytest + + +# ── CSS color validation ───────────────────────────────────────────── +_CSS_COLOR_RE = re.compile( + r"^(" + r"#[0-9a-fA-F]{3,8}" # hex + r"|rgba?\([^)]+\)" # rgb/rgba + r"|hsla?\([^)]+\)" # hsl/hsla + r"|[a-zA-Z]+" # named color + r")$" +) + +REQUIRED_VARS = [ + '--bg-primary', '--bg-secondary', '--bg-sidebar', '--bg-hover', '--border', + '--text-primary', '--text-secondary', '--text-muted', '--accent', + '--accent-green', '--tag-bg', '--tag-text', '--code-bg', '--search-bg', + '--scrollbar', '--resize-handle', '--overlay-bg', + '--danger', '--danger-bg', '--success', '--success-bg', + '--accent-card', '--accent-bg', '--accent-text', + '--green', '--green-bg', '--purple', '--purple-bg', + '--surface', '--surface2', '--surface3', + '--border-md', '--text', '--text-2', '--text-3', + '--ok', '--warn', '--ai', '--err', +] + +AVAILABLE_MODES = ['dark', 'light', 'high-contrast', 'sepia'] + + +# ── Theme data extracted from themes.js ────────────────────────────── +# We parse the JS file to extract theme definitions for validation. +def _parse_themes_from_js(): + """Read themes.js and build a dict of theme definitions using regex.""" + import os + js_path = os.path.join(os.path.dirname(__file__), '..', 'frontend', 'js', 'themes.js') + with open(js_path, encoding='utf-8') as f: + content = f.read() + return content + + +def _extract_theme_names(content): + """Extract top-level theme keys from THEMES dict.""" + return re.findall(r"'([a-z0-9-]+)':\s*\{", content.split('var THEMES')[1].split('// ── Theme Engine')[0]) + + +def _count_css_vars_in_block(content, keyword, section_start): + """Count CSS variable assignments starting from a position.""" + return len(re.findall(r"'--[a-z]", content[section_start:section_start + 2000])) + + +class TestThemeStructure: + """Validate theme data from themes.js.""" + + @pytest.fixture(autouse=True) + def load_js(self): + self.content = _parse_themes_from_js() + + def test_themes_file_exists(self): + """themes.js should exist and be readable.""" + assert len(self.content) > 1000 + + def test_has_available_modes(self): + """AVAILABLE_MODES should define all 4 modes.""" + assert "AVAILABLE_MODES = ['dark', 'light', 'high-contrast', 'sepia']" in self.content + + def test_has_mode_generators(self): + """Mode generators for high-contrast and sepia should exist.""" + assert '_genHighContrast' in self.content + assert '_genSepia' in self.content + + def test_theme_count(self): + """Should have 15 built-in themes.""" + names = _extract_theme_names(self.content) + assert len(names) == 15, f"Expected 15 themes, found {len(names)}: {names}" + + def test_required_export_functions(self): + """All required functions should be exported.""" + required_exports = [ + 'THEMES', 'AVAILABLE_MODES', + 'applyTheme', 'toggleThemeMode', + 'getCurrentTheme', 'getCurrentMode', + 'setTheme', 'listThemes', 'listModes', + 'exportTheme', 'exportAllThemes', 'importTheme', + 'deleteCustomTheme', 'loadCustomThemes', + 'initThemes', + ] + export_block = self.content.split('export {')[1].split('}')[0] + for fn in required_exports: + assert fn in export_block, f"Missing export: {fn}" + + def test_high_contrast_vars_count(self): + """High-contrast generator should define 30+ CSS vars.""" + hc_start = self.content.index('_genHighContrast') + hc_section = self.content[hc_start:hc_start + 2000] + var_count = len(re.findall(r"'--[a-z]", hc_section)) + assert var_count >= 30, f"High-contrast defines {var_count} vars, expected >= 30" + + def test_sepia_vars_count(self): + """Sepia generator should define 30+ CSS vars.""" + sepia_start = self.content.index('_genSepia') + sepia_section = self.content[sepia_start:sepia_start + 2000] + var_count = len(re.findall(r"'--[a-z]", sepia_section)) + assert var_count >= 30, f"Sepia defines {var_count} vars, expected >= 30" + + def test_high_contrast_colors_valid(self): + """High-contrast mode should use valid CSS colors.""" + hc_start = self.content.index('_genHighContrast') + hc_section = self.content[hc_start:hc_start + 2000] + colors = re.findall(r":\s*'([^']+)'", hc_section) + for color in colors: + assert _CSS_COLOR_RE.match(color), f"Invalid CSS color in high-contrast: {color}" + + def test_sepia_colors_valid(self): + """Sepia mode should use valid CSS colors.""" + sepia_start = self.content.index('_genSepia') + sepia_section = self.content[sepia_start:sepia_start + 2000] + colors = re.findall(r":\s*'([^']+)'", sepia_section) + for color in colors: + assert _CSS_COLOR_RE.match(color), f"Invalid CSS color in sepia: {color}" + + def test_getvars_handles_all_modes(self): + """_getVars should handle dark, light, high-contrast, and sepia.""" + assert "mode === 'high-contrast'" in self.content + assert "mode === 'sepia'" in self.content + + def test_import_theme_validates_input(self): + """importTheme should validate JSON structure.""" + assert "missing modes" in self.content + assert "at least dark or light" in self.content + + def test_localstorage_keys(self): + """Custom themes should use proper localStorage key prefix.""" + assert "obsigate-custom-" in self.content + assert "obsigate-theme" in self.content + assert "obsigate-theme-mode" in self.content + + def test_each_theme_has_dark_and_light(self): + """Each built-in theme should define dark and light modes.""" + names = _extract_theme_names(self.content) + for name in names: + pattern = re.compile( + rf"'{re.escape(name)}':\s*\{{.*?modes:\s*\{{.*?dark:\s*\{{", + re.DOTALL + ) + assert pattern.search(self.content), f"Theme '{name}' missing dark mode" + + def test_toggle_cycles_all_modes(self): + """toggleThemeMode should cycle through all 4 modes.""" + assert '% AVAILABLE_MODES.length' in self.content + + +class TestI18nKeys: + """Validate that all theme i18n keys exist in both locales.""" + + @pytest.fixture(autouse=True) + def load_locales(self): + import os + base = os.path.join(os.path.dirname(__file__), '..', 'frontend', 'locales') + with open(os.path.join(base, 'en.json'), encoding='utf-8') as f: + self.en = json.load(f) + with open(os.path.join(base, 'fr.json'), encoding='utf-8') as f: + self.fr = json.load(f) + + def test_theme_keys_en(self): + """English locale should have all theme keys.""" + required = [ + 'theme.dark', 'theme.light', 'theme.high_contrast', 'theme.sepia', + 'theme.title', 'theme.change', + 'theme.export_all', 'theme.import', + 'theme.import_success', 'theme.import_error', + 'theme.delete_confirm', + ] + for key in required: + assert key in self.en, f"Missing EN key: {key}" + + def test_theme_keys_fr(self): + """French locale should have all theme keys.""" + required = [ + 'theme.dark', 'theme.light', 'theme.high_contrast', 'theme.sepia', + 'theme.title', 'theme.change', + 'theme.export_all', 'theme.import', + 'theme.import_success', 'theme.import_error', + 'theme.delete_confirm', + ] + for key in required: + assert key in self.fr, f"Missing FR key: {key}" + + def test_import_success_has_placeholder(self): + """import_success key should accept a {count} parameter.""" + assert '{count}' in self.en['theme.import_success'] + assert '{count}' in self.fr['theme.import_success'] + + def test_key_parity(self): + """EN and FR should have the same set of theme.* keys.""" + en_theme = {k for k in self.en if k.startswith('theme.')} + fr_theme = {k for k in self.fr if k.startswith('theme.')} + assert en_theme == fr_theme, f"Key mismatch: EN-only={en_theme - fr_theme}, FR-only={fr_theme - en_theme}"