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 <noreply@anthropic.com>
This commit is contained in:
259
mcp-servers/viz-platform/mcp_server/theme_store.py
Normal file
259
mcp-servers/viz-platform/mcp_server/theme_store.py
Normal file
@@ -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
|
||||
Reference in New Issue
Block a user