development #184
@@ -15,6 +15,7 @@ from .config import VizPlatformConfig
|
|||||||
from .dmc_tools import DMCTools
|
from .dmc_tools import DMCTools
|
||||||
from .chart_tools import ChartTools
|
from .chart_tools import ChartTools
|
||||||
from .layout_tools import LayoutTools
|
from .layout_tools import LayoutTools
|
||||||
|
from .theme_tools import ThemeTools
|
||||||
|
|
||||||
# Suppress noisy MCP validation warnings on stderr
|
# Suppress noisy MCP validation warnings on stderr
|
||||||
logging.basicConfig(level=logging.INFO)
|
logging.basicConfig(level=logging.INFO)
|
||||||
@@ -32,8 +33,8 @@ class VizPlatformMCPServer:
|
|||||||
self.dmc_tools = DMCTools()
|
self.dmc_tools = DMCTools()
|
||||||
self.chart_tools = ChartTools()
|
self.chart_tools = ChartTools()
|
||||||
self.layout_tools = LayoutTools()
|
self.layout_tools = LayoutTools()
|
||||||
|
self.theme_tools = ThemeTools()
|
||||||
# Tool handlers will be added in subsequent issues
|
# Tool handlers will be added in subsequent issues
|
||||||
# self.theme_tools = None
|
|
||||||
# self.page_tools = None
|
# self.page_tools = None
|
||||||
|
|
||||||
async def initialize(self):
|
async def initialize(self):
|
||||||
@@ -280,10 +281,93 @@ class VizPlatformMCPServer:
|
|||||||
))
|
))
|
||||||
|
|
||||||
# Theme tools (Issue #175)
|
# Theme tools (Issue #175)
|
||||||
# - theme_create
|
tools.append(Tool(
|
||||||
# - theme_extend
|
name="theme_create",
|
||||||
# - theme_validate
|
description=(
|
||||||
# - theme_export_css
|
"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 tools (Issue #176)
|
||||||
# - page_create
|
# - page_create
|
||||||
@@ -407,7 +491,62 @@ class VizPlatformMCPServer:
|
|||||||
text=json.dumps(result, indent=2)
|
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)
|
# Page tools (Issue #176)
|
||||||
|
|
||||||
raise ValueError(f"Unknown tool: {name}")
|
raise ValueError(f"Unknown tool: {name}")
|
||||||
|
|||||||
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
|
||||||
391
mcp-servers/viz-platform/mcp_server/theme_tools.py
Normal file
391
mcp-servers/viz-platform/mcp_server/theme_tools.py
Normal file
@@ -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
|
||||||
Reference in New Issue
Block a user