From 797b3064c9e5e40edab5bbf409d57f51b381b7f8 Mon Sep 17 00:00:00 2001 From: lmiranda Date: Mon, 26 Jan 2026 11:53:21 -0500 Subject: [PATCH] feat(viz-platform): implement theme tools (#175) - Add theme_create tool: create themes with design tokens - Add theme_extend tool: extend existing themes with overrides - Add theme_validate tool: validate theme completeness - Add theme_export_css tool: export as CSS custom properties - Add ThemeStore for theme persistence (user and project level) - Default theme based on Mantine defaults with 47 CSS variables Co-Authored-By: Claude Opus 4.5 --- mcp-servers/viz-platform/mcp_server/server.py | 151 ++++++- .../viz-platform/mcp_server/theme_store.py | 259 ++++++++++++ .../viz-platform/mcp_server/theme_tools.py | 391 ++++++++++++++++++ 3 files changed, 795 insertions(+), 6 deletions(-) create mode 100644 mcp-servers/viz-platform/mcp_server/theme_store.py create mode 100644 mcp-servers/viz-platform/mcp_server/theme_tools.py diff --git a/mcp-servers/viz-platform/mcp_server/server.py b/mcp-servers/viz-platform/mcp_server/server.py index 49ee278..c746f1d 100644 --- a/mcp-servers/viz-platform/mcp_server/server.py +++ b/mcp-servers/viz-platform/mcp_server/server.py @@ -15,6 +15,7 @@ from .config import VizPlatformConfig from .dmc_tools import DMCTools from .chart_tools import ChartTools from .layout_tools import LayoutTools +from .theme_tools import ThemeTools # Suppress noisy MCP validation warnings on stderr logging.basicConfig(level=logging.INFO) @@ -32,8 +33,8 @@ class VizPlatformMCPServer: self.dmc_tools = DMCTools() self.chart_tools = ChartTools() self.layout_tools = LayoutTools() + self.theme_tools = ThemeTools() # Tool handlers will be added in subsequent issues - # self.theme_tools = None # self.page_tools = None async def initialize(self): @@ -280,10 +281,93 @@ class VizPlatformMCPServer: )) # Theme tools (Issue #175) - # - theme_create - # - theme_extend - # - theme_validate - # - theme_export_css + tools.append(Tool( + name="theme_create", + description=( + "Create a new design theme with tokens. " + "Tokens include colors, spacing, typography, radii. " + "Missing tokens are filled from defaults." + ), + inputSchema={ + "type": "object", + "properties": { + "name": { + "type": "string", + "description": "Unique theme name" + }, + "tokens": { + "type": "object", + "description": ( + "Design tokens: colors (primary, background, text), " + "spacing (xs-xl), typography (fontFamily, fontSize), radii (sm-xl)" + ) + } + }, + "required": ["name", "tokens"] + } + )) + + tools.append(Tool( + name="theme_extend", + description=( + "Create a new theme by extending an existing one. " + "Only specify the tokens you want to override." + ), + inputSchema={ + "type": "object", + "properties": { + "base_theme": { + "type": "string", + "description": "Theme to extend (e.g., 'default')" + }, + "overrides": { + "type": "object", + "description": "Token overrides to apply" + }, + "new_name": { + "type": "string", + "description": "Name for the new theme (optional)" + } + }, + "required": ["base_theme", "overrides"] + } + )) + + tools.append(Tool( + name="theme_validate", + description=( + "Validate a theme for completeness. " + "Checks for required tokens and common issues." + ), + inputSchema={ + "type": "object", + "properties": { + "theme_name": { + "type": "string", + "description": "Theme to validate" + } + }, + "required": ["theme_name"] + } + )) + + tools.append(Tool( + name="theme_export_css", + description=( + "Export a theme as CSS custom properties. " + "Generates :root CSS variables for all tokens." + ), + inputSchema={ + "type": "object", + "properties": { + "theme_name": { + "type": "string", + "description": "Theme to export" + } + }, + "required": ["theme_name"] + } + )) # Page tools (Issue #176) # - page_create @@ -407,7 +491,62 @@ class VizPlatformMCPServer: text=json.dumps(result, indent=2) )] - # Theme tools (Issue #175) + # Theme tools + elif name == "theme_create": + theme_name = arguments.get('name') + tokens = arguments.get('tokens', {}) + if not theme_name: + return [TextContent( + type="text", + text=json.dumps({"error": "name is required"}, indent=2) + )] + result = await self.theme_tools.theme_create(theme_name, tokens) + return [TextContent( + type="text", + text=json.dumps(result, indent=2) + )] + + elif name == "theme_extend": + base_theme = arguments.get('base_theme') + overrides = arguments.get('overrides', {}) + new_name = arguments.get('new_name') + if not base_theme: + return [TextContent( + type="text", + text=json.dumps({"error": "base_theme is required"}, indent=2) + )] + result = await self.theme_tools.theme_extend(base_theme, overrides, new_name) + return [TextContent( + type="text", + text=json.dumps(result, indent=2) + )] + + elif name == "theme_validate": + theme_name = arguments.get('theme_name') + if not theme_name: + return [TextContent( + type="text", + text=json.dumps({"error": "theme_name is required"}, indent=2) + )] + result = await self.theme_tools.theme_validate(theme_name) + return [TextContent( + type="text", + text=json.dumps(result, indent=2) + )] + + elif name == "theme_export_css": + theme_name = arguments.get('theme_name') + if not theme_name: + return [TextContent( + type="text", + text=json.dumps({"error": "theme_name is required"}, indent=2) + )] + result = await self.theme_tools.theme_export_css(theme_name) + return [TextContent( + type="text", + text=json.dumps(result, indent=2) + )] + # Page tools (Issue #176) raise ValueError(f"Unknown tool: {name}") diff --git a/mcp-servers/viz-platform/mcp_server/theme_store.py b/mcp-servers/viz-platform/mcp_server/theme_store.py new file mode 100644 index 0000000..b2335ee --- /dev/null +++ b/mcp-servers/viz-platform/mcp_server/theme_store.py @@ -0,0 +1,259 @@ +""" +Theme storage and persistence for viz-platform. + +Handles saving/loading themes from user and project locations. +""" +import json +import logging +from pathlib import Path +from typing import Dict, List, Optional, Any + +logger = logging.getLogger(__name__) + + +# Default theme based on Mantine defaults +DEFAULT_THEME = { + "name": "default", + "version": "1.0.0", + "tokens": { + "colors": { + "primary": "#228be6", + "secondary": "#868e96", + "success": "#40c057", + "warning": "#fab005", + "error": "#fa5252", + "info": "#15aabf", + "background": { + "base": "#ffffff", + "subtle": "#f8f9fa", + "dark": "#212529" + }, + "text": { + "primary": "#212529", + "secondary": "#495057", + "muted": "#868e96", + "inverse": "#ffffff" + }, + "border": "#dee2e6" + }, + "spacing": { + "xs": "4px", + "sm": "8px", + "md": "16px", + "lg": "24px", + "xl": "32px" + }, + "typography": { + "fontFamily": "Inter, -apple-system, BlinkMacSystemFont, Segoe UI, Roboto, sans-serif", + "fontFamilyMono": "ui-monospace, SFMono-Regular, Menlo, Monaco, monospace", + "fontSize": { + "xs": "12px", + "sm": "14px", + "md": "16px", + "lg": "18px", + "xl": "20px" + }, + "fontWeight": { + "normal": 400, + "medium": 500, + "semibold": 600, + "bold": 700 + }, + "lineHeight": { + "tight": 1.25, + "normal": 1.5, + "relaxed": 1.75 + } + }, + "radii": { + "none": "0px", + "sm": "4px", + "md": "8px", + "lg": "16px", + "xl": "24px", + "full": "9999px" + }, + "shadows": { + "none": "none", + "sm": "0 1px 2px 0 rgb(0 0 0 / 0.05)", + "md": "0 4px 6px -1px rgb(0 0 0 / 0.1)", + "lg": "0 10px 15px -3px rgb(0 0 0 / 0.1)", + "xl": "0 20px 25px -5px rgb(0 0 0 / 0.1)" + }, + "transitions": { + "fast": "150ms", + "normal": "300ms", + "slow": "500ms" + } + } +} + + +# Required token categories for validation +REQUIRED_TOKEN_CATEGORIES = ["colors", "spacing", "typography", "radii"] + + +class ThemeStore: + """ + Store and manage design themes. + + Handles persistence to user-level and project-level locations. + """ + + def __init__(self, project_dir: Optional[Path] = None): + """ + Initialize theme store. + + Args: + project_dir: Project directory for project-level themes + """ + self.project_dir = project_dir + self._themes: Dict[str, Dict[str, Any]] = {} + self._active_theme: Optional[str] = None + + # Load default theme + self._themes["default"] = DEFAULT_THEME.copy() + + @property + def user_themes_dir(self) -> Path: + """User-level themes directory.""" + return Path.home() / ".config" / "claude" / "themes" + + @property + def project_themes_dir(self) -> Optional[Path]: + """Project-level themes directory.""" + if self.project_dir: + return self.project_dir / ".viz-platform" / "themes" + return None + + def load_themes(self) -> int: + """ + Load themes from user and project directories. + + Project themes take precedence over user themes. + + Returns: + Number of themes loaded + """ + count = 0 + + # Load user themes + if self.user_themes_dir.exists(): + for theme_file in self.user_themes_dir.glob("*.json"): + try: + with open(theme_file, 'r') as f: + theme = json.load(f) + name = theme.get('name', theme_file.stem) + self._themes[name] = theme + count += 1 + logger.debug(f"Loaded user theme: {name}") + except Exception as e: + logger.warning(f"Failed to load theme {theme_file}: {e}") + + # Load project themes (override user themes) + if self.project_themes_dir and self.project_themes_dir.exists(): + for theme_file in self.project_themes_dir.glob("*.json"): + try: + with open(theme_file, 'r') as f: + theme = json.load(f) + name = theme.get('name', theme_file.stem) + self._themes[name] = theme + count += 1 + logger.debug(f"Loaded project theme: {name}") + except Exception as e: + logger.warning(f"Failed to load theme {theme_file}: {e}") + + return count + + def save_theme( + self, + theme: Dict[str, Any], + location: str = "project" + ) -> Path: + """ + Save a theme to disk. + + Args: + theme: Theme dict to save + location: "user" or "project" + + Returns: + Path where theme was saved + """ + name = theme.get('name', 'unnamed') + + if location == "user": + target_dir = self.user_themes_dir + else: + target_dir = self.project_themes_dir + if not target_dir: + target_dir = self.user_themes_dir + + target_dir.mkdir(parents=True, exist_ok=True) + theme_path = target_dir / f"{name}.json" + + with open(theme_path, 'w') as f: + json.dump(theme, f, indent=2) + + # Update in-memory store + self._themes[name] = theme + + return theme_path + + def get_theme(self, name: str) -> Optional[Dict[str, Any]]: + """Get a theme by name.""" + return self._themes.get(name) + + def list_themes(self) -> List[str]: + """List all available theme names.""" + return list(self._themes.keys()) + + def set_active_theme(self, name: str) -> bool: + """ + Set the active theme. + + Args: + name: Theme name to activate + + Returns: + True if theme was activated + """ + if name in self._themes: + self._active_theme = name + return True + return False + + def get_active_theme(self) -> Optional[Dict[str, Any]]: + """Get the currently active theme.""" + if self._active_theme: + return self._themes.get(self._active_theme) + return None + + def delete_theme(self, name: str) -> bool: + """ + Delete a theme. + + Args: + name: Theme name to delete + + Returns: + True if theme was deleted + """ + if name == "default": + return False # Cannot delete default theme + + if name in self._themes: + del self._themes[name] + + # Remove file if exists + for themes_dir in [self.user_themes_dir, self.project_themes_dir]: + if themes_dir and themes_dir.exists(): + theme_path = themes_dir / f"{name}.json" + if theme_path.exists(): + theme_path.unlink() + + if self._active_theme == name: + self._active_theme = None + + return True + return False diff --git a/mcp-servers/viz-platform/mcp_server/theme_tools.py b/mcp-servers/viz-platform/mcp_server/theme_tools.py new file mode 100644 index 0000000..8ff0eaa --- /dev/null +++ b/mcp-servers/viz-platform/mcp_server/theme_tools.py @@ -0,0 +1,391 @@ +""" +Theme management tools for viz-platform. + +Provides design token-based theming system for consistent visual styling. +""" +import copy +import logging +from typing import Dict, List, Optional, Any + +from .theme_store import ThemeStore, DEFAULT_THEME, REQUIRED_TOKEN_CATEGORIES + +logger = logging.getLogger(__name__) + + +class ThemeTools: + """ + Design token-based theming tools. + + Creates and manages themes that integrate with DMC and Plotly. + """ + + def __init__(self, store: Optional[ThemeStore] = None): + """ + Initialize theme tools. + + Args: + store: Optional ThemeStore for persistence + """ + self.store = store or ThemeStore() + + async def theme_create( + self, + name: str, + tokens: Dict[str, Any] + ) -> Dict[str, Any]: + """ + Create a new theme with design tokens. + + Args: + name: Unique theme name + tokens: Design tokens dict with colors, spacing, typography, radii + + Returns: + Dict with: + - name: Theme name + - tokens: Full token set (merged with defaults) + - validation: Validation results + """ + # Check for name collision + if self.store.get_theme(name) and name != "default": + return { + "error": f"Theme '{name}' already exists. Use theme_extend to modify it.", + "name": name + } + + # Start with default tokens and merge provided ones + theme_tokens = copy.deepcopy(DEFAULT_THEME["tokens"]) + theme_tokens = self._deep_merge(theme_tokens, tokens) + + # Create theme object + theme = { + "name": name, + "version": "1.0.0", + "tokens": theme_tokens + } + + # Validate the theme + validation = self._validate_tokens(theme_tokens) + + # Save to store + self.store._themes[name] = theme + + return { + "name": name, + "tokens": theme_tokens, + "validation": validation, + "complete": validation["complete"] + } + + async def theme_extend( + self, + base_theme: str, + overrides: Dict[str, Any], + new_name: Optional[str] = None + ) -> Dict[str, Any]: + """ + Create a new theme by extending an existing one. + + Args: + base_theme: Name of theme to extend + overrides: Token overrides to apply + new_name: Optional name for the new theme (defaults to base_theme_extended) + + Returns: + Dict with the new theme or error + """ + # Get base theme + base = self.store.get_theme(base_theme) + if not base: + available = self.store.list_themes() + return { + "error": f"Base theme '{base_theme}' not found. Available: {available}", + "name": None + } + + # Determine new name + name = new_name or f"{base_theme}_extended" + + # Check for collision + if self.store.get_theme(name) and name != base_theme: + return { + "error": f"Theme '{name}' already exists. Choose a different name.", + "name": name + } + + # Merge tokens + theme_tokens = copy.deepcopy(base.get("tokens", {})) + theme_tokens = self._deep_merge(theme_tokens, overrides) + + # Create theme + theme = { + "name": name, + "version": "1.0.0", + "extends": base_theme, + "tokens": theme_tokens + } + + # Validate + validation = self._validate_tokens(theme_tokens) + + # Save to store + self.store._themes[name] = theme + + return { + "name": name, + "extends": base_theme, + "tokens": theme_tokens, + "validation": validation, + "complete": validation["complete"] + } + + async def theme_validate(self, theme_name: str) -> Dict[str, Any]: + """ + Validate a theme for completeness. + + Args: + theme_name: Theme name to validate + + Returns: + Dict with: + - valid: bool + - complete: bool (all optional tokens present) + - missing: List of missing required tokens + - warnings: List of warnings + """ + theme = self.store.get_theme(theme_name) + if not theme: + available = self.store.list_themes() + return { + "error": f"Theme '{theme_name}' not found. Available: {available}", + "valid": False + } + + tokens = theme.get("tokens", {}) + validation = self._validate_tokens(tokens) + + return { + "theme_name": theme_name, + "valid": validation["valid"], + "complete": validation["complete"], + "missing_required": validation["missing_required"], + "missing_optional": validation["missing_optional"], + "warnings": validation["warnings"] + } + + async def theme_export_css(self, theme_name: str) -> Dict[str, Any]: + """ + Export a theme as CSS custom properties. + + Args: + theme_name: Theme name to export + + Returns: + Dict with: + - css: CSS custom properties string + - variables: List of variable names + """ + theme = self.store.get_theme(theme_name) + if not theme: + available = self.store.list_themes() + return { + "error": f"Theme '{theme_name}' not found. Available: {available}", + "css": None + } + + tokens = theme.get("tokens", {}) + css_vars = [] + var_names = [] + + # Convert tokens to CSS custom properties + css_vars.append(f"/* Theme: {theme_name} */") + css_vars.append(":root {") + + # Colors + colors = tokens.get("colors", {}) + css_vars.append(" /* Colors */") + for key, value in self._flatten_tokens(colors, "color").items(): + var_name = f"--{key}" + css_vars.append(f" {var_name}: {value};") + var_names.append(var_name) + + # Spacing + spacing = tokens.get("spacing", {}) + css_vars.append("\n /* Spacing */") + for key, value in spacing.items(): + var_name = f"--spacing-{key}" + css_vars.append(f" {var_name}: {value};") + var_names.append(var_name) + + # Typography + typography = tokens.get("typography", {}) + css_vars.append("\n /* Typography */") + for key, value in self._flatten_tokens(typography, "font").items(): + var_name = f"--{key}" + css_vars.append(f" {var_name}: {value};") + var_names.append(var_name) + + # Radii + radii = tokens.get("radii", {}) + css_vars.append("\n /* Border Radius */") + for key, value in radii.items(): + var_name = f"--radius-{key}" + css_vars.append(f" {var_name}: {value};") + var_names.append(var_name) + + # Shadows + shadows = tokens.get("shadows", {}) + if shadows: + css_vars.append("\n /* Shadows */") + for key, value in shadows.items(): + var_name = f"--shadow-{key}" + css_vars.append(f" {var_name}: {value};") + var_names.append(var_name) + + # Transitions + transitions = tokens.get("transitions", {}) + if transitions: + css_vars.append("\n /* Transitions */") + for key, value in transitions.items(): + var_name = f"--transition-{key}" + css_vars.append(f" {var_name}: {value};") + var_names.append(var_name) + + css_vars.append("}") + + css_content = "\n".join(css_vars) + + return { + "theme_name": theme_name, + "css": css_content, + "variable_count": len(var_names), + "variables": var_names + } + + async def theme_list(self) -> Dict[str, Any]: + """ + List all available themes. + + Returns: + Dict with theme names and active theme + """ + themes = self.store.list_themes() + active = self.store._active_theme + + theme_info = {} + for name in themes: + theme = self.store.get_theme(name) + theme_info[name] = { + "extends": theme.get("extends"), + "version": theme.get("version", "1.0.0") + } + + return { + "themes": theme_info, + "active_theme": active, + "count": len(themes) + } + + async def theme_activate(self, theme_name: str) -> Dict[str, Any]: + """ + Set the active theme. + + Args: + theme_name: Theme to activate + + Returns: + Dict with activation status + """ + if self.store.set_active_theme(theme_name): + return { + "active_theme": theme_name, + "success": True + } + return { + "error": f"Theme '{theme_name}' not found.", + "success": False + } + + def _validate_tokens(self, tokens: Dict[str, Any]) -> Dict[str, Any]: + """Validate token structure and completeness.""" + missing_required = [] + missing_optional = [] + warnings = [] + + # Check required categories + for category in REQUIRED_TOKEN_CATEGORIES: + if category not in tokens: + missing_required.append(category) + + # Check colors structure + colors = tokens.get("colors", {}) + required_colors = ["primary", "background", "text"] + for color in required_colors: + if color not in colors: + missing_required.append(f"colors.{color}") + + # Check spacing + spacing = tokens.get("spacing", {}) + required_spacing = ["xs", "sm", "md", "lg"] + for size in required_spacing: + if size not in spacing: + missing_optional.append(f"spacing.{size}") + + # Check typography + typography = tokens.get("typography", {}) + if "fontFamily" not in typography: + missing_optional.append("typography.fontFamily") + if "fontSize" not in typography: + missing_optional.append("typography.fontSize") + + # Check radii + radii = tokens.get("radii", {}) + if "sm" not in radii and "md" not in radii: + missing_optional.append("radii.sm or radii.md") + + # Warnings for common issues + if "shadows" not in tokens: + warnings.append("No shadows defined - components may have no elevation") + if "transitions" not in tokens: + warnings.append("No transitions defined - animations will use defaults") + + return { + "valid": len(missing_required) == 0, + "complete": len(missing_required) == 0 and len(missing_optional) == 0, + "missing_required": missing_required, + "missing_optional": missing_optional, + "warnings": warnings + } + + def _deep_merge( + self, + base: Dict[str, Any], + override: Dict[str, Any] + ) -> Dict[str, Any]: + """Deep merge two dictionaries.""" + result = copy.deepcopy(base) + + for key, value in override.items(): + if key in result and isinstance(result[key], dict) and isinstance(value, dict): + result[key] = self._deep_merge(result[key], value) + else: + result[key] = value + + return result + + def _flatten_tokens( + self, + tokens: Dict[str, Any], + prefix: str + ) -> Dict[str, str]: + """Flatten nested token dict for CSS export.""" + result = {} + + for key, value in tokens.items(): + if isinstance(value, dict): + nested = self._flatten_tokens(value, f"{prefix}-{key}") + result.update(nested) + else: + result[f"{prefix}-{key}"] = str(value) + + return result