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