Migrated from design-system-swarm with fresh git history.
Old project history preserved in /home/overbits/apps/design-system-swarm
Core components:
- MCP Server (Python FastAPI with mcp 1.23.1)
- Claude Plugin (agents, commands, skills, strategies, hooks, core)
- DSS Backend (dss-mvp1 - token translation, Figma sync)
- Admin UI (Node.js/React)
- Server (Node.js/Express)
- Storybook integration (dss-mvp1/.storybook)
Self-contained configuration:
- All paths relative or use DSS_BASE_PATH=/home/overbits/dss
- PYTHONPATH configured for dss-mvp1 and dss-claude-plugin
- .env file with all configuration
- Claude plugin uses ${CLAUDE_PLUGIN_ROOT} for portability
Migration completed: $(date)
🤖 Clean migration with full functionality preserved
221 lines
7.4 KiB
Python
221 lines
7.4 KiB
Python
"""
|
|
Theme Merger
|
|
|
|
Merges base DSS theme with translation mappings and custom props.
|
|
"""
|
|
|
|
from datetime import datetime, timezone
|
|
from typing import Any, Dict, Optional, Union
|
|
|
|
from dss.models.theme import DesignToken, Theme, TokenCategory
|
|
from dss.themes.default_themes import get_default_dark_theme, get_default_light_theme
|
|
|
|
from .models import ResolvedTheme, ResolvedToken, TranslationRegistry
|
|
from .resolver import TokenResolver
|
|
|
|
|
|
class ThemeMerger:
|
|
"""
|
|
Merges base DSS theme with project-specific customizations.
|
|
|
|
The merge hierarchy:
|
|
1. Base Theme (DSS Light or Dark)
|
|
2. Translation Mappings (external tokens -> DSS)
|
|
3. Custom Props (project-specific extensions)
|
|
|
|
Usage:
|
|
merger = ThemeMerger(registry)
|
|
resolved = await merger.merge(base_theme="light")
|
|
"""
|
|
|
|
def __init__(self, registry: TranslationRegistry):
|
|
"""
|
|
Initialize merger with translation registry.
|
|
|
|
Args:
|
|
registry: TranslationRegistry with loaded dictionaries
|
|
"""
|
|
self.registry = registry
|
|
self.resolver = TokenResolver(registry)
|
|
|
|
async def merge(
|
|
self, base_theme: str = "light", project_name: Optional[str] = None
|
|
) -> ResolvedTheme:
|
|
"""
|
|
Merge base theme with translations and custom props.
|
|
|
|
Args:
|
|
base_theme: Base theme name ("light" or "dark")
|
|
project_name: Project name for resolved theme
|
|
|
|
Returns:
|
|
ResolvedTheme with all tokens resolved
|
|
"""
|
|
# Get base theme
|
|
if base_theme == "light":
|
|
theme = get_default_light_theme()
|
|
elif base_theme == "dark":
|
|
theme = get_default_dark_theme()
|
|
else:
|
|
raise ValueError(f"Unknown base theme: {base_theme}")
|
|
|
|
# Convert theme tokens to dict for resolution
|
|
base_tokens = self._theme_to_dict(theme)
|
|
|
|
# Resolve all mapped tokens
|
|
resolved_tokens = self.resolver.resolve_all_mappings(base_tokens)
|
|
|
|
# Separate core tokens from custom props
|
|
core_tokens = {}
|
|
custom_props = {}
|
|
|
|
for dss_path, resolved in resolved_tokens.items():
|
|
if resolved.is_custom:
|
|
custom_props[dss_path] = resolved
|
|
else:
|
|
core_tokens[dss_path] = resolved
|
|
|
|
# Add base theme tokens that aren't in mappings
|
|
for token_name, token in theme.tokens.items():
|
|
# Normalize token name to DSS path
|
|
dss_path = self._normalize_to_dss_path(token_name)
|
|
if dss_path not in core_tokens:
|
|
core_tokens[dss_path] = ResolvedToken(
|
|
dss_path=dss_path,
|
|
value=token.value,
|
|
is_custom=False,
|
|
provenance=[f"base_theme: {base_theme}"],
|
|
)
|
|
|
|
return ResolvedTheme(
|
|
name=project_name or f"resolved-{base_theme}",
|
|
version="1.0.0",
|
|
base_theme=base_theme,
|
|
tokens=core_tokens,
|
|
custom_props=custom_props,
|
|
translations_applied=[dict_name for dict_name in self.registry.dictionaries.keys()],
|
|
resolved_at=datetime.now(timezone.utc),
|
|
)
|
|
|
|
def _theme_to_dict(self, theme: Theme) -> Dict[str, Any]:
|
|
"""Convert Theme object to nested dict for resolution."""
|
|
result = {}
|
|
for token_name, token in theme.tokens.items():
|
|
# Convert flat token names to nested structure
|
|
parts = self._normalize_to_dss_path(token_name).split(".")
|
|
current = result
|
|
for part in parts[:-1]:
|
|
if part not in current:
|
|
current[part] = {}
|
|
elif not isinstance(current[part], dict):
|
|
# Skip if this path is already set to a value
|
|
continue
|
|
current = current[part]
|
|
current[parts[-1]] = token.value
|
|
return result
|
|
|
|
def _normalize_to_dss_path(self, token_name: str) -> str:
|
|
"""Normalize token name to DSS canonical path."""
|
|
# Handle various formats
|
|
normalized = token_name.replace("-", ".").replace("_", ".")
|
|
|
|
# Map common prefixes
|
|
prefix_map = {
|
|
"space.": "spacing.",
|
|
"radius.": "border.radius.",
|
|
"text.": "typography.size.",
|
|
}
|
|
|
|
for old, new in prefix_map.items():
|
|
if normalized.startswith(old):
|
|
normalized = new + normalized[len(old) :]
|
|
break
|
|
|
|
return normalized
|
|
|
|
async def merge_custom_props(
|
|
self, resolved_theme: ResolvedTheme, additional_props: Dict[str, Any]
|
|
) -> ResolvedTheme:
|
|
"""
|
|
Add additional custom props to a resolved theme.
|
|
|
|
Args:
|
|
resolved_theme: Existing resolved theme
|
|
additional_props: Additional custom props to merge
|
|
|
|
Returns:
|
|
Updated ResolvedTheme
|
|
"""
|
|
for prop_name, prop_value in additional_props.items():
|
|
resolved_theme.custom_props[prop_name] = ResolvedToken(
|
|
dss_path=prop_name,
|
|
value=prop_value,
|
|
is_custom=True,
|
|
provenance=["additional_custom_prop"],
|
|
)
|
|
|
|
resolved_theme.resolved_at = datetime.now(timezone.utc)
|
|
return resolved_theme
|
|
|
|
def export_as_theme(self, resolved: ResolvedTheme) -> Theme:
|
|
"""
|
|
Convert ResolvedTheme back to Theme model.
|
|
|
|
Args:
|
|
resolved: ResolvedTheme to convert
|
|
|
|
Returns:
|
|
Theme model instance
|
|
"""
|
|
tokens = {}
|
|
|
|
# Add core tokens
|
|
for dss_path, resolved_token in resolved.tokens.items():
|
|
token_name = dss_path.replace(".", "-")
|
|
tokens[token_name] = DesignToken(
|
|
name=token_name,
|
|
value=resolved_token.value,
|
|
type=self._infer_type(dss_path, resolved_token.value),
|
|
category=self._infer_category(dss_path),
|
|
source=f"resolved:{resolved.base_theme}",
|
|
)
|
|
|
|
# Add custom props
|
|
for dss_path, resolved_token in resolved.custom_props.items():
|
|
token_name = dss_path.replace(".", "-")
|
|
tokens[token_name] = DesignToken(
|
|
name=token_name,
|
|
value=resolved_token.value,
|
|
type=self._infer_type(dss_path, resolved_token.value),
|
|
category=TokenCategory.OTHER,
|
|
source="custom_prop",
|
|
)
|
|
|
|
return Theme(name=resolved.name, version=resolved.version, tokens=tokens)
|
|
|
|
def _infer_type(self, path: str, value: Any) -> str:
|
|
"""Infer token type from path and value."""
|
|
if "color" in path:
|
|
return "color"
|
|
if "spacing" in path or "size" in path or "radius" in path:
|
|
return "dimension"
|
|
if "font" in path:
|
|
return "typography"
|
|
if "shadow" in path:
|
|
return "shadow"
|
|
return "string"
|
|
|
|
def _infer_category(self, path: str) -> TokenCategory:
|
|
"""Infer token category from DSS path."""
|
|
if path.startswith("color"):
|
|
return TokenCategory.COLOR
|
|
if path.startswith("spacing"):
|
|
return TokenCategory.SPACING
|
|
if path.startswith("typography") or path.startswith("font"):
|
|
return TokenCategory.TYPOGRAPHY
|
|
if path.startswith("border") or path.startswith("radius"):
|
|
return TokenCategory.RADIUS
|
|
if path.startswith("shadow"):
|
|
return TokenCategory.SHADOW
|
|
return TokenCategory.OTHER
|