diff --git a/.agentready-config.example.yaml b/.agentready-config.example.yaml index 769e23c6..1f34fc56 100644 --- a/.agentready-config.example.yaml +++ b/.agentready-config.example.yaml @@ -57,6 +57,29 @@ language_overrides: {} # Custom output directory (default: .agentready/) # output_dir: ./custom-reports +# HTML Report Theme (default: 'default') +# Available themes: default, light, dark, high-contrast, solarized-dark, dracula +report_theme: default + +# Custom Theme Colors (optional - overrides report_theme if provided) +# All fields required if custom_theme is specified +# custom_theme: +# background: "#0a0e27" +# surface: "#1a1f3a" +# surface_elevated: "#252b4a" +# primary: "#8b5cf6" +# primary_light: "#a78bfa" +# primary_dark: "#6d28d9" +# text_primary: "#f8fafc" +# text_secondary: "#cbd5e1" +# text_muted: "#94a3b8" +# success: "#10b981" +# warning: "#f59e0b" +# danger: "#ef4444" +# neutral: "#64748b" +# border: "#334155" +# shadow: "rgba(0, 0, 0, 0.5)" + # Example: Increase weight for CLAUDE.md and tests # This increases CLAUDE.md from 10% to 15% and test_coverage from 3% to 5% # Other attributes are automatically rescaled to maintain sum of 1.0 diff --git a/src/agentready/models/__init__.py b/src/agentready/models/__init__.py index be622871..3400eabe 100644 --- a/src/agentready/models/__init__.py +++ b/src/agentready/models/__init__.py @@ -15,6 +15,7 @@ ) from agentready.models.metadata import AssessmentMetadata from agentready.models.repository import Repository +from agentready.models.theme import Theme, validate_theme_contrast __all__ = [ "Assessment", @@ -30,4 +31,6 @@ "Fix", "MultiStepFix", "Repository", + "Theme", + "validate_theme_contrast", ] diff --git a/src/agentready/models/config.py b/src/agentready/models/config.py index 25e71b70..a9f4e914 100644 --- a/src/agentready/models/config.py +++ b/src/agentready/models/config.py @@ -13,12 +13,16 @@ class Config: excluded_attributes: Attributes to skip language_overrides: Force language detection (lang → [patterns]) output_dir: Custom output directory (None uses default .agentready/) + report_theme: Theme name for HTML reports (default, dark, light, etc.) + custom_theme: Custom theme colors (overrides report_theme if provided) """ weights: dict[str, float] excluded_attributes: list[str] language_overrides: dict[str, list[str]] output_dir: Path | None + report_theme: str = "default" + custom_theme: dict[str, str] | None = None def __post_init__(self): """Validate config data after initialization.""" @@ -46,6 +50,8 @@ def to_dict(self) -> dict: "excluded_attributes": self.excluded_attributes, "language_overrides": self.language_overrides, "output_dir": str(self.output_dir) if self.output_dir else None, + "report_theme": self.report_theme, + "custom_theme": self.custom_theme, } def get_weight(self, attribute_id: str, default: float) -> float: diff --git a/src/agentready/models/theme.py b/src/agentready/models/theme.py new file mode 100644 index 00000000..eb091ed5 --- /dev/null +++ b/src/agentready/models/theme.py @@ -0,0 +1,300 @@ +"""Theme system for HTML reports with accessibility validation.""" + +from dataclasses import dataclass +from typing import ClassVar + + +@dataclass +class Theme: + """HTML report theme with color scheme and styling. + + All themes must maintain WCAG 2.1 AA contrast ratios (4.5:1 for normal text). + """ + + name: str + display_name: str + background: str + surface: str + surface_elevated: str + primary: str + primary_light: str + primary_dark: str + text_primary: str + text_secondary: str + text_muted: str + success: str + warning: str + danger: str + neutral: str + border: str + shadow: str + + # Built-in themes + BUILT_IN_THEMES: ClassVar[dict[str, "Theme"]] = {} + + def to_css_vars(self) -> dict[str, str]: + """Convert theme to CSS custom properties dictionary.""" + return { + "--background": self.background, + "--surface": self.surface, + "--surface-elevated": self.surface_elevated, + "--primary": self.primary, + "--primary-light": self.primary_light, + "--primary-dark": self.primary_dark, + "--text-primary": self.text_primary, + "--text-secondary": self.text_secondary, + "--text-muted": self.text_muted, + "--success": self.success, + "--warning": self.warning, + "--danger": self.danger, + "--neutral": self.neutral, + "--border": self.border, + "--shadow": self.shadow, + } + + def to_dict(self) -> dict[str, str]: + """Convert to dictionary for JSON serialization.""" + return { + "name": self.name, + "display_name": self.display_name, + "background": self.background, + "surface": self.surface, + "surface_elevated": self.surface_elevated, + "primary": self.primary, + "primary_light": self.primary_light, + "primary_dark": self.primary_dark, + "text_primary": self.text_primary, + "text_secondary": self.text_secondary, + "text_muted": self.text_muted, + "success": self.success, + "warning": self.warning, + "danger": self.danger, + "neutral": self.neutral, + "border": self.border, + "shadow": self.shadow, + } + + @classmethod + def from_dict(cls, data: dict) -> "Theme": + """Create theme from dictionary.""" + return cls( + name=data["name"], + display_name=data["display_name"], + background=data["background"], + surface=data["surface"], + surface_elevated=data["surface_elevated"], + primary=data["primary"], + primary_light=data["primary_light"], + primary_dark=data["primary_dark"], + text_primary=data["text_primary"], + text_secondary=data["text_secondary"], + text_muted=data["text_muted"], + success=data["success"], + warning=data["warning"], + danger=data["danger"], + neutral=data["neutral"], + border=data["border"], + shadow=data["shadow"], + ) + + @classmethod + def get_theme(cls, theme_name: str) -> "Theme": + """Get built-in theme by name. + + Args: + theme_name: Theme identifier (default, dark, light, etc.) + + Returns: + Theme object + + Raises: + KeyError: If theme not found + """ + if theme_name not in cls.BUILT_IN_THEMES: + raise KeyError( + f"Theme '{theme_name}' not found. " + f"Available themes: {', '.join(cls.BUILT_IN_THEMES.keys())}" + ) + return cls.BUILT_IN_THEMES[theme_name] + + @classmethod + def get_available_themes(cls) -> list[str]: + """Get list of available built-in theme names.""" + return list(cls.BUILT_IN_THEMES.keys()) + + +# Define built-in themes +Theme.BUILT_IN_THEMES = { + "default": Theme( + name="default", + display_name="Default (Dark Professional)", + background="#0a0e27", + surface="#1a1f3a", + surface_elevated="#252b4a", + primary="#8b5cf6", + primary_light="#a78bfa", + primary_dark="#6d28d9", + text_primary="#f8fafc", + text_secondary="#cbd5e1", + text_muted="#94a3b8", + success="#10b981", + warning="#f59e0b", + danger="#ef4444", + neutral="#64748b", + border="#334155", + shadow="rgba(0, 0, 0, 0.5)", + ), + "light": Theme( + name="light", + display_name="Light", + background="#f8fafc", + surface="#ffffff", + surface_elevated="#f1f5f9", + primary="#8b5cf6", + primary_light="#a78bfa", + primary_dark="#6d28d9", + text_primary="#0f172a", + text_secondary="#334155", + text_muted="#64748b", + success="#10b981", + warning="#f59e0b", + danger="#ef4444", + neutral="#94a3b8", + border="#e2e8f0", + shadow="rgba(0, 0, 0, 0.1)", + ), + "dark": Theme( + name="dark", + display_name="Dark", + background="#0f172a", + surface="#1e293b", + surface_elevated="#334155", + primary="#8b5cf6", + primary_light="#a78bfa", + primary_dark="#6d28d9", + text_primary="#f1f5f9", + text_secondary="#cbd5e1", + text_muted="#94a3b8", + success="#10b981", + warning="#f59e0b", + danger="#ef4444", + neutral="#64748b", + border="#475569", + shadow="rgba(0, 0, 0, 0.6)", + ), + "high-contrast": Theme( + name="high-contrast", + display_name="High Contrast", + background="#000000", + surface="#1a1a1a", + surface_elevated="#2d2d2d", + primary="#00ffff", + primary_light="#66ffff", + primary_dark="#00cccc", + text_primary="#ffffff", + text_secondary="#e0e0e0", + text_muted="#b0b0b0", + success="#00ff00", + warning="#ffff00", + danger="#ff0000", + neutral="#808080", + border="#ffffff", + shadow="rgba(255, 255, 255, 0.2)", + ), + "solarized-dark": Theme( + name="solarized-dark", + display_name="Solarized Dark", + background="#002b36", + surface="#073642", + surface_elevated="#0e4e5c", + primary="#268bd2", + primary_light="#5fa8db", + primary_dark="#1e6fa9", + text_primary="#fdf6e3", + text_secondary="#eee8d5", + text_muted="#93a1a1", + success="#859900", + warning="#b58900", + danger="#dc322f", + neutral="#586e75", + border="#586e75", + shadow="rgba(0, 0, 0, 0.5)", + ), + "dracula": Theme( + name="dracula", + display_name="Dracula", + background="#282a36", + surface="#44475a", + surface_elevated="#6272a4", + primary="#bd93f9", + primary_light="#d4b5ff", + primary_dark="#9b72d6", + text_primary="#f8f8f2", + text_secondary="#e6e6e6", + text_muted="#6272a4", + success="#50fa7b", + warning="#f1fa8c", + danger="#ff5555", + neutral="#8be9fd", + border="#6272a4", + shadow="rgba(0, 0, 0, 0.5)", + ), +} + + +def validate_theme_contrast(theme: Theme) -> list[str]: + """Validate theme meets WCAG 2.1 AA contrast requirements. + + Args: + theme: Theme to validate + + Returns: + List of validation warnings (empty if all valid) + """ + warnings = [] + + # Simple luminance calculation (simplified WCAG formula) + def hex_to_rgb(hex_color: str) -> tuple[int, int, int]: + """Convert hex color to RGB tuple.""" + hex_color = hex_color.lstrip("#") + if len(hex_color) == 3: + hex_color = "".join([c * 2 for c in hex_color]) + return tuple(int(hex_color[i : i + 2], 16) for i in (0, 2, 4)) + + def relative_luminance(rgb: tuple[int, int, int]) -> float: + """Calculate relative luminance (simplified).""" + r, g, b = [x / 255.0 for x in rgb] + r = r / 12.92 if r <= 0.03928 else ((r + 0.055) / 1.055) ** 2.4 + g = g / 12.92 if g <= 0.03928 else ((g + 0.055) / 1.055) ** 2.4 + b = b / 12.92 if b <= 0.03928 else ((b + 0.055) / 1.055) ** 2.4 + return 0.2126 * r + 0.7152 * g + 0.0722 * b + + def contrast_ratio(color1: str, color2: str) -> float: + """Calculate contrast ratio between two colors.""" + try: + l1 = relative_luminance(hex_to_rgb(color1)) + l2 = relative_luminance(hex_to_rgb(color2)) + lighter = max(l1, l2) + darker = min(l1, l2) + return (lighter + 0.05) / (darker + 0.05) + except (ValueError, IndexError): + return 0.0 + + # Check key contrast pairs (WCAG 2.1 AA requires 4.5:1 for normal text) + min_contrast = 4.5 + + contrast_checks = [ + (theme.text_primary, theme.background, "Primary text on background"), + (theme.text_primary, theme.surface, "Primary text on surface"), + (theme.text_secondary, theme.background, "Secondary text on background"), + (theme.text_secondary, theme.surface, "Secondary text on surface"), + ] + + for fg, bg, description in contrast_checks: + ratio = contrast_ratio(fg, bg) + if ratio < min_contrast: + warnings.append( + f"{description}: {ratio:.2f}:1 (minimum {min_contrast}:1 required)" + ) + + return warnings diff --git a/src/agentready/reporters/html.py b/src/agentready/reporters/html.py index f0a92654..19bd3efd 100644 --- a/src/agentready/reporters/html.py +++ b/src/agentready/reporters/html.py @@ -6,6 +6,7 @@ from jinja2 import Environment, PackageLoader, select_autoescape from ..models.assessment import Assessment +from ..models.theme import Theme from .base import BaseReporter @@ -45,6 +46,15 @@ def generate(self, assessment: Assessment, output_path: Path) -> Path: # Load template template = self.env.get_template("report.html.j2") + # Determine theme to use + theme = self._resolve_theme(assessment.config) + + # Get all available themes for theme switcher + available_themes = { + name: Theme.get_theme(name).to_dict() + for name in Theme.get_available_themes() + } + # Prepare data for template template_data = { "repository": assessment.repository, @@ -60,6 +70,11 @@ def generate(self, assessment: Assessment, output_path: Path) -> Path: "metadata": assessment.metadata, # Embed assessment JSON for JavaScript "assessment_json": json.dumps(assessment.to_dict()), + # Theme data + "theme": theme, + "theme_name": theme.name, + "available_themes": available_themes, + "available_themes_json": json.dumps(available_themes), } # Render template @@ -71,3 +86,34 @@ def generate(self, assessment: Assessment, output_path: Path) -> Path: f.write(html_content) return output_path + + def _resolve_theme(self, config) -> Theme: + """Resolve theme from config. + + Args: + config: Assessment config (may be None) + + Returns: + Resolved Theme object + """ + # No config or no custom theme → use default or configured theme + if not config: + return Theme.get_theme("default") + + # Custom theme provided → build from custom_theme dict + if config.custom_theme: + return Theme.from_dict( + { + "name": "custom", + "display_name": "Custom", + **config.custom_theme, + } + ) + + # Use configured theme name + theme_name = getattr(config, "report_theme", "default") + try: + return Theme.get_theme(theme_name) + except KeyError: + # Fall back to default if theme not found + return Theme.get_theme("default") diff --git a/src/agentready/templates/report.html.j2 b/src/agentready/templates/report.html.j2 index aa10bbef..9205f24c 100644 --- a/src/agentready/templates/report.html.j2 +++ b/src/agentready/templates/report.html.j2 @@ -1,5 +1,5 @@ - + @@ -14,22 +14,10 @@ } :root { - /* Dark professional color scheme */ - --background: #0a0e27; /* Almost black with blue tint */ - --surface: #1a1f3a; /* Dark blue surface */ - --surface-elevated: #252b4a; /* Slightly lighter surface */ - --primary: #8b5cf6; /* Purple (accent) */ - --primary-light: #a78bfa; /* Light purple */ - --primary-dark: #6d28d9; /* Dark purple */ - --text-primary: #f8fafc; /* Almost white */ - --text-secondary: #cbd5e1; /* Light gray */ - --text-muted: #94a3b8; /* Muted gray */ - --success: #10b981; /* Green (pass) */ - --warning: #f59e0b; /* Amber (warning) */ - --danger: #ef4444; /* Red (fail) */ - --neutral: #64748b; /* Gray (skipped) */ - --border: #334155; /* Dark border */ - --shadow: rgba(0, 0, 0, 0.5); /* Deep shadows */ + /* Default theme colors - will be overridden by active theme */ + {% for var, value in theme.to_css_vars().items() %} + {{ var }}: {{ value }}; + {% endfor %} } body { @@ -480,9 +468,67 @@ .hidden { display: none !important; } + + /* Theme Switcher */ + .theme-switcher { + position: fixed; + top: 20px; + right: 20px; + z-index: 1000; + display: flex; + gap: 10px; + align-items: center; + } + + .theme-switcher label { + font-size: 17px; + color: var(--text-secondary); + font-weight: 600; + } + + .theme-select { + padding: 8px 12px; + border: 2px solid var(--border); + border-radius: 6px; + background: var(--surface); + color: var(--text-primary); + font-size: 17px; + cursor: pointer; + transition: all 0.2s; + } + + .theme-select:hover { + border-color: var(--primary); + } + + .theme-select:focus { + outline: none; + border-color: var(--primary); + box-shadow: 0 0 0 3px rgba(139, 92, 246, 0.1); + } + + @media (max-width: 768px) { + .theme-switcher { + position: static; + justify-content: center; + margin-bottom: 20px; + } + } + +
+ + +
+

🤖 AgentReady Assessment Report

@@ -670,6 +716,83 @@ // Embedded assessment data (properly escaped to prevent XSS) const ASSESSMENT = JSON.parse({{ assessment_json|tojson }}); + // Embedded theme data + const THEMES = JSON.parse({{ available_themes_json|tojson }}); + const DEFAULT_THEME = {{ theme_name|tojson }}; + + // Theme management with localStorage persistence + function applyTheme(themeName) { + const theme = THEMES[themeName]; + if (!theme) { + console.warn(`Theme ${themeName} not found, using default`); + themeName = DEFAULT_THEME; + } + + const themeData = THEMES[themeName]; + const root = document.documentElement; + + // Apply CSS custom properties + root.style.setProperty('--background', themeData.background); + root.style.setProperty('--surface', themeData.surface); + root.style.setProperty('--surface-elevated', themeData.surface_elevated); + root.style.setProperty('--primary', themeData.primary); + root.style.setProperty('--primary-light', themeData.primary_light); + root.style.setProperty('--primary-dark', themeData.primary_dark); + root.style.setProperty('--text-primary', themeData.text_primary); + root.style.setProperty('--text-secondary', themeData.text_secondary); + root.style.setProperty('--text-muted', themeData.text_muted); + root.style.setProperty('--success', themeData.success); + root.style.setProperty('--warning', themeData.warning); + root.style.setProperty('--danger', themeData.danger); + root.style.setProperty('--neutral', themeData.neutral); + root.style.setProperty('--border', themeData.border); + root.style.setProperty('--shadow', themeData.shadow); + + // Update data attribute for potential CSS targeting + root.setAttribute('data-theme', themeName); + + // Save preference to localStorage + try { + localStorage.setItem('agentready-theme', themeName); + } catch (e) { + console.warn('Could not save theme preference to localStorage:', e); + } + } + + // Load theme from localStorage on page load + function loadSavedTheme() { + try { + const savedTheme = localStorage.getItem('agentready-theme'); + if (savedTheme && THEMES[savedTheme]) { + return savedTheme; + } + } catch (e) { + console.warn('Could not load theme preference from localStorage:', e); + } + return DEFAULT_THEME; + } + + // Initialize theme on page load + document.addEventListener('DOMContentLoaded', () => { + const savedTheme = loadSavedTheme(); + const themeSelect = document.getElementById('theme-select'); + + // Apply saved theme if different from default + if (savedTheme !== DEFAULT_THEME) { + applyTheme(savedTheme); + if (themeSelect) { + themeSelect.value = savedTheme; + } + } + + // Set up theme switcher + if (themeSelect) { + themeSelect.addEventListener('change', (e) => { + applyTheme(e.target.value); + }); + } + }); + // Toggle finding expansion function toggleFinding(header) { const finding = header.parentElement; diff --git a/tests/integration/test_scan_workflow.py b/tests/integration/test_scan_workflow.py index 1e3d1805..7eeb2627 100644 --- a/tests/integration/test_scan_workflow.py +++ b/tests/integration/test_scan_workflow.py @@ -3,6 +3,8 @@ from pathlib import Path from agentready.assessors.documentation import CLAUDEmdAssessor, READMEAssessor +from agentready.models.config import Config +from agentready.models.theme import Theme from agentready.reporters.html import HTMLReporter from agentready.reporters.markdown import MarkdownReporter from agentready.services.scanner import Scanner @@ -83,3 +85,155 @@ def test_markdown_report_generation(self, tmp_path): assert "# 🤖 AgentReady Assessment Report" in content assert "## 📊 Summary" in content assert assessment.repository.name in content + + def test_html_report_with_light_theme(self, tmp_path): + """Test HTML report generation with light theme.""" + repo_path = Path(__file__).parent.parent.parent + + # Create config with light theme + config = Config( + weights={}, + excluded_attributes=[], + language_overrides={}, + output_dir=None, + report_theme="light", + ) + + scanner = Scanner(repo_path, config=config) + assessors = [CLAUDEmdAssessor()] + assessment = scanner.scan(assessors, verbose=False) + + # Generate HTML report + reporter = HTMLReporter() + output_file = tmp_path / "test_report_light.html" + result = reporter.generate(assessment, output_file) + + # Verify file was created + assert result.exists() + + # Verify light theme is applied + with open(result, "r") as f: + content = f.read() + assert 'data-theme="light"' in content + assert "#f8fafc" in content # Light background color + + def test_html_report_with_dark_theme(self, tmp_path): + """Test HTML report generation with dark theme.""" + repo_path = Path(__file__).parent.parent.parent + + # Create config with dark theme + config = Config( + weights={}, + excluded_attributes=[], + language_overrides={}, + output_dir=None, + report_theme="dark", + ) + + scanner = Scanner(repo_path, config=config) + assessors = [CLAUDEmdAssessor()] + assessment = scanner.scan(assessors, verbose=False) + + # Generate HTML report + reporter = HTMLReporter() + output_file = tmp_path / "test_report_dark.html" + result = reporter.generate(assessment, output_file) + + # Verify file was created + assert result.exists() + + # Verify dark theme is applied + with open(result, "r") as f: + content = f.read() + assert 'data-theme="dark"' in content + assert "#0f172a" in content # Dark background color + + def test_html_report_with_custom_theme(self, tmp_path): + """Test HTML report generation with custom theme.""" + repo_path = Path(__file__).parent.parent.parent + + # Create config with custom theme + custom_colors = { + "background": "#1a1a2e", + "surface": "#16213e", + "surface_elevated": "#0f3460", + "primary": "#e94560", + "primary_light": "#ff6b6b", + "primary_dark": "#c72c41", + "text_primary": "#eaeaea", + "text_secondary": "#d4d4d4", + "text_muted": "#a0a0a0", + "success": "#4ecca3", + "warning": "#f39c12", + "danger": "#e74c3c", + "neutral": "#95a5a6", + "border": "#34495e", + "shadow": "rgba(0, 0, 0, 0.6)", + } + + config = Config( + weights={}, + excluded_attributes=[], + language_overrides={}, + output_dir=None, + custom_theme=custom_colors, + ) + + scanner = Scanner(repo_path, config=config) + assessors = [CLAUDEmdAssessor()] + assessment = scanner.scan(assessors, verbose=False) + + # Generate HTML report + reporter = HTMLReporter() + output_file = tmp_path / "test_report_custom.html" + result = reporter.generate(assessment, output_file) + + # Verify file was created + assert result.exists() + + # Verify custom colors are applied + with open(result, "r") as f: + content = f.read() + assert "#1a1a2e" in content # Custom background + assert "#e94560" in content # Custom primary + + def test_html_report_theme_switcher_present(self, tmp_path): + """Test HTML report includes theme switcher.""" + repo_path = Path(__file__).parent.parent.parent + + scanner = Scanner(repo_path, config=None) + assessors = [CLAUDEmdAssessor()] + assessment = scanner.scan(assessors, verbose=False) + + # Generate HTML report + reporter = HTMLReporter() + output_file = tmp_path / "test_report_switcher.html" + result = reporter.generate(assessment, output_file) + + # Verify theme switcher elements are present + with open(result, "r") as f: + content = f.read() + assert 'id="theme-select"' in content + assert "Theme:" in content + assert "const THEMES = " in content + assert "applyTheme" in content + assert "localStorage" in content + + def test_html_report_all_themes_embedded(self, tmp_path): + """Test HTML report embeds all available themes.""" + repo_path = Path(__file__).parent.parent.parent + + scanner = Scanner(repo_path, config=None) + assessors = [CLAUDEmdAssessor()] + assessment = scanner.scan(assessors, verbose=False) + + # Generate HTML report + reporter = HTMLReporter() + output_file = tmp_path / "test_report_themes.html" + result = reporter.generate(assessment, output_file) + + # Verify all themes are embedded + with open(result, "r") as f: + content = f.read() + for theme_name in Theme.get_available_themes(): + assert theme_name in content diff --git a/tests/unit/test_theme.py b/tests/unit/test_theme.py new file mode 100644 index 00000000..56b197fe --- /dev/null +++ b/tests/unit/test_theme.py @@ -0,0 +1,266 @@ +"""Unit tests for theme system.""" + +import pytest + +from agentready.models.theme import Theme, validate_theme_contrast + + +class TestTheme: + """Test Theme model and functionality.""" + + def test_theme_creation(self): + """Test creating a theme with all required fields.""" + theme = Theme( + name="test", + display_name="Test Theme", + background="#000000", + surface="#111111", + surface_elevated="#222222", + primary="#3333ff", + primary_light="#5555ff", + primary_dark="#1111ff", + text_primary="#ffffff", + text_secondary="#eeeeee", + text_muted="#cccccc", + success="#00ff00", + warning="#ffff00", + danger="#ff0000", + neutral="#888888", + border="#444444", + shadow="rgba(0, 0, 0, 0.5)", + ) + + assert theme.name == "test" + assert theme.display_name == "Test Theme" + assert theme.background == "#000000" + assert theme.primary == "#3333ff" + + def test_theme_to_css_vars(self): + """Test converting theme to CSS custom properties.""" + theme = Theme.get_theme("default") + css_vars = theme.to_css_vars() + + assert "--background" in css_vars + assert "--surface" in css_vars + assert "--primary" in css_vars + assert "--text-primary" in css_vars + assert len(css_vars) == 15 # All theme properties + + def test_theme_to_dict(self): + """Test converting theme to dictionary.""" + theme = Theme.get_theme("default") + theme_dict = theme.to_dict() + + assert theme_dict["name"] == "default" + assert theme_dict["display_name"] == "Default (Dark Professional)" + assert "background" in theme_dict + assert "primary" in theme_dict + + def test_theme_from_dict(self): + """Test creating theme from dictionary.""" + theme_data = { + "name": "custom", + "display_name": "Custom Theme", + "background": "#000000", + "surface": "#111111", + "surface_elevated": "#222222", + "primary": "#3333ff", + "primary_light": "#5555ff", + "primary_dark": "#1111ff", + "text_primary": "#ffffff", + "text_secondary": "#eeeeee", + "text_muted": "#cccccc", + "success": "#00ff00", + "warning": "#ffff00", + "danger": "#ff0000", + "neutral": "#888888", + "border": "#444444", + "shadow": "rgba(0, 0, 0, 0.5)", + } + + theme = Theme.from_dict(theme_data) + + assert theme.name == "custom" + assert theme.display_name == "Custom Theme" + assert theme.background == "#000000" + + def test_get_theme_default(self): + """Test getting default theme.""" + theme = Theme.get_theme("default") + + assert theme.name == "default" + assert theme.display_name == "Default (Dark Professional)" + + def test_get_theme_light(self): + """Test getting light theme.""" + theme = Theme.get_theme("light") + + assert theme.name == "light" + assert theme.display_name == "Light" + assert theme.background == "#f8fafc" # Light background + + def test_get_theme_dark(self): + """Test getting dark theme.""" + theme = Theme.get_theme("dark") + + assert theme.name == "dark" + assert theme.display_name == "Dark" + assert theme.background == "#0f172a" # Dark background + + def test_get_theme_high_contrast(self): + """Test getting high contrast theme.""" + theme = Theme.get_theme("high-contrast") + + assert theme.name == "high-contrast" + assert theme.display_name == "High Contrast" + assert theme.background == "#000000" # Pure black + + def test_get_theme_solarized(self): + """Test getting solarized dark theme.""" + theme = Theme.get_theme("solarized-dark") + + assert theme.name == "solarized-dark" + assert theme.display_name == "Solarized Dark" + + def test_get_theme_dracula(self): + """Test getting dracula theme.""" + theme = Theme.get_theme("dracula") + + assert theme.name == "dracula" + assert theme.display_name == "Dracula" + + def test_get_theme_not_found(self): + """Test getting non-existent theme raises KeyError.""" + with pytest.raises(KeyError, match="Theme 'nonexistent' not found"): + Theme.get_theme("nonexistent") + + def test_get_available_themes(self): + """Test getting list of available themes.""" + themes = Theme.get_available_themes() + + assert "default" in themes + assert "light" in themes + assert "dark" in themes + assert "high-contrast" in themes + assert "solarized-dark" in themes + assert "dracula" in themes + assert len(themes) == 6 + + def test_built_in_themes_complete(self): + """Test all built-in themes have required fields.""" + for theme_name in Theme.get_available_themes(): + theme = Theme.get_theme(theme_name) + + # Verify all fields are present + assert theme.name + assert theme.display_name + assert theme.background + assert theme.surface + assert theme.surface_elevated + assert theme.primary + assert theme.primary_light + assert theme.primary_dark + assert theme.text_primary + assert theme.text_secondary + assert theme.text_muted + assert theme.success + assert theme.warning + assert theme.danger + assert theme.neutral + assert theme.border + assert theme.shadow + + +class TestThemeValidation: + """Test theme accessibility validation.""" + + def test_validate_theme_high_contrast(self): + """Test high contrast theme passes validation.""" + theme = Theme.get_theme("high-contrast") + warnings = validate_theme_contrast(theme) + + # High contrast theme should have no warnings + assert len(warnings) == 0 + + def test_validate_theme_default(self): + """Test default theme validation.""" + theme = Theme.get_theme("default") + warnings = validate_theme_contrast(theme) + + # Default theme should be accessible + assert len(warnings) == 0 + + def test_validate_theme_light(self): + """Test light theme validation.""" + theme = Theme.get_theme("light") + warnings = validate_theme_contrast(theme) + + # Light theme should be accessible + assert len(warnings) == 0 + + def test_validate_all_built_in_themes(self): + """Test all built-in themes meet accessibility standards.""" + for theme_name in Theme.get_available_themes(): + theme = Theme.get_theme(theme_name) + warnings = validate_theme_contrast(theme) + + # All built-in themes should pass WCAG 2.1 AA + assert len(warnings) == 0, f"Theme {theme_name} has warnings: {warnings}" + + def test_validate_poor_contrast_theme(self): + """Test validation detects poor contrast.""" + # Create a theme with poor contrast + poor_theme = Theme( + name="poor", + display_name="Poor Contrast", + background="#ffffff", # White background + surface="#ffffff", + surface_elevated="#f0f0f0", + primary="#ff0000", + primary_light="#ff5555", + primary_dark="#cc0000", + text_primary="#cccccc", # Light gray on white - poor contrast + text_secondary="#dddddd", # Even worse + text_muted="#eeeeee", + success="#00ff00", + warning="#ffff00", + danger="#ff0000", + neutral="#888888", + border="#f0f0f0", + shadow="rgba(0, 0, 0, 0.1)", + ) + + warnings = validate_theme_contrast(poor_theme) + + # Should detect poor contrast + assert len(warnings) > 0 + assert any("Primary text on background" in w for w in warnings) + + +class TestThemeRoundtrip: + """Test theme serialization and deserialization.""" + + def test_theme_dict_roundtrip(self): + """Test converting theme to dict and back preserves data.""" + original = Theme.get_theme("default") + theme_dict = original.to_dict() + restored = Theme.from_dict(theme_dict) + + assert restored.name == original.name + assert restored.display_name == original.display_name + assert restored.background == original.background + assert restored.surface == original.surface + assert restored.primary == original.primary + assert restored.text_primary == original.text_primary + + def test_css_vars_have_correct_format(self): + """Test CSS variables are correctly formatted.""" + theme = Theme.get_theme("default") + css_vars = theme.to_css_vars() + + for key, value in css_vars.items(): + # Keys should start with -- + assert key.startswith("--") + # Values should be valid CSS colors or shadows + assert isinstance(value, str) + assert len(value) > 0