Files
dss/dss-mvp1/dss/translations/merger.py
Digital Production Factory 276ed71f31 Initial commit: Clean DSS implementation
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
2025-12-09 18:45:48 -03:00

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