""" Storybook Theme Generator Generates Storybook theme configurations from design tokens. """ import json from pathlib import Path from typing import List, Dict, Any, Optional from dataclasses import dataclass, field @dataclass class StorybookTheme: """Storybook theme configuration.""" name: str = "dss-theme" base: str = "light" # 'light' or 'dark' # Brand brand_title: str = "Design System" brand_url: str = "" brand_image: str = "" brand_target: str = "_self" # Colors color_primary: str = "#3B82F6" color_secondary: str = "#10B981" # UI Colors app_bg: str = "#FFFFFF" app_content_bg: str = "#FFFFFF" app_border_color: str = "#E5E7EB" # Text colors text_color: str = "#1F2937" text_inverse_color: str = "#FFFFFF" text_muted_color: str = "#6B7280" # Toolbar bar_text_color: str = "#6B7280" bar_selected_color: str = "#3B82F6" bar_bg: str = "#FFFFFF" # Form colors input_bg: str = "#FFFFFF" input_border: str = "#D1D5DB" input_text_color: str = "#1F2937" input_border_radius: int = 4 # Typography font_base: str = '"Inter", -apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, sans-serif' font_code: str = '"Fira Code", "Monaco", monospace' def to_dict(self) -> Dict[str, Any]: return { "base": self.base, "brandTitle": self.brand_title, "brandUrl": self.brand_url, "brandImage": self.brand_image, "brandTarget": self.brand_target, "colorPrimary": self.color_primary, "colorSecondary": self.color_secondary, "appBg": self.app_bg, "appContentBg": self.app_content_bg, "appBorderColor": self.app_border_color, "textColor": self.text_color, "textInverseColor": self.text_inverse_color, "textMutedColor": self.text_muted_color, "barTextColor": self.bar_text_color, "barSelectedColor": self.bar_selected_color, "barBg": self.bar_bg, "inputBg": self.input_bg, "inputBorder": self.input_border, "inputTextColor": self.input_text_color, "inputBorderRadius": self.input_border_radius, "fontBase": self.font_base, "fontCode": self.font_code, } class ThemeGenerator: """ Generates Storybook theme configurations from design tokens. """ # Token name mappings to Storybook theme properties TOKEN_MAPPINGS = { # Primary/Secondary "color.primary.500": "color_primary", "color.primary.600": "color_primary", "color.secondary.500": "color_secondary", "color.accent.500": "color_secondary", # Backgrounds "color.neutral.50": "app_bg", "color.background": "app_bg", "color.surface": "app_content_bg", # Borders "color.neutral.200": "app_border_color", "color.border": "app_border_color", # Text "color.neutral.900": "text_color", "color.neutral.800": "text_color", "color.foreground": "text_color", "color.neutral.500": "text_muted_color", "color.muted": "text_muted_color", # Input "color.neutral.300": "input_border", "radius.md": "input_border_radius", } def __init__(self): pass def generate_from_tokens( self, tokens: List[Dict[str, Any]], brand_title: str = "Design System", base: str = "light", ) -> StorybookTheme: """ Generate Storybook theme from design tokens. Args: tokens: List of token dicts with 'name' and 'value' brand_title: Brand title for Storybook base: Base theme ('light' or 'dark') Returns: StorybookTheme configured from tokens """ theme = StorybookTheme( name="dss-theme", base=base, brand_title=brand_title, ) # Map tokens to theme properties for token in tokens: name = token.get("name", "") value = token.get("value", "") # Check direct mappings if name in self.TOKEN_MAPPINGS: prop = self.TOKEN_MAPPINGS[name] setattr(theme, prop, value) continue # Check partial matches name_lower = name.lower() if "primary" in name_lower and "500" in name_lower: theme.color_primary = value elif "secondary" in name_lower and "500" in name_lower: theme.color_secondary = value elif "background" in name_lower and self._is_light_color(value): theme.app_bg = value elif "foreground" in name_lower or ("text" in name_lower and "color" in name_lower): theme.text_color = value # Adjust for dark mode if base == "dark": theme = self._adjust_for_dark_mode(theme) return theme def _is_light_color(self, value: str) -> bool: """Check if a color value is light (for background suitability).""" if not value.startswith("#"): return True # Assume light if not hex # Parse hex color hex_color = value.lstrip("#") if len(hex_color) == 3: hex_color = "".join(c * 2 for c in hex_color) try: r = int(hex_color[0:2], 16) g = int(hex_color[2:4], 16) b = int(hex_color[4:6], 16) # Calculate luminance luminance = (0.299 * r + 0.587 * g + 0.114 * b) / 255 return luminance > 0.5 except (ValueError, IndexError): return True def _adjust_for_dark_mode(self, theme: StorybookTheme) -> StorybookTheme: """Adjust theme for dark mode if colors aren't already dark.""" # Swap light/dark if needed if self._is_light_color(theme.app_bg): theme.app_bg = "#1F2937" theme.app_content_bg = "#111827" theme.app_border_color = "#374151" theme.text_color = "#F9FAFB" theme.text_muted_color = "#9CA3AF" theme.bar_bg = "#1F2937" theme.bar_text_color = "#9CA3AF" theme.input_bg = "#374151" theme.input_border = "#4B5563" theme.input_text_color = "#F9FAFB" return theme def generate_theme_file( self, theme: StorybookTheme, format: str = "ts", ) -> str: """ Generate Storybook theme file content. Args: theme: StorybookTheme to export format: Output format ('ts', 'js', 'json') Returns: Theme file content as string """ if format == "json": return json.dumps(theme.to_dict(), indent=2) theme_dict = theme.to_dict() if format == "ts": lines = [ "import { create } from '@storybook/theming/create';", "", "export const dssTheme = create({", ] else: # js lines = [ "const { create } = require('@storybook/theming/create');", "", "module.exports = create({", ] for key, value in theme_dict.items(): if isinstance(value, str): lines.append(f" {key}: '{value}',") else: lines.append(f" {key}: {value},") lines.extend([ "});", "", ]) return "\n".join(lines) def generate_manager_file(self, theme_import: str = "./dss-theme") -> str: """ Generate Storybook manager.ts file. Args: theme_import: Import path for theme Returns: Manager file content """ return f"""import {{ addons }} from '@storybook/manager-api'; import {{ dssTheme }} from '{theme_import}'; addons.setConfig({{ theme: dssTheme, }}); """ def generate_preview_file( self, tokens: List[Dict[str, Any]], include_css_vars: bool = True, ) -> str: """ Generate Storybook preview.ts file with token CSS variables. Args: tokens: List of token dicts include_css_vars: Include CSS variable injection Returns: Preview file content """ lines = [ "import type { Preview } from '@storybook/react';", "", ] if include_css_vars: # Generate CSS variables from tokens css_vars = [] for token in tokens: name = token.get("name", "").replace(".", "-") value = token.get("value", "") css_vars.append(f" --{name}: {value};") lines.extend([ "// Inject design tokens as CSS variables", "const tokenStyles = `", ":root {", ]) lines.extend(css_vars) lines.extend([ "}", "`;", "", "// Add styles to document", "const styleSheet = document.createElement('style');", "styleSheet.textContent = tokenStyles;", "document.head.appendChild(styleSheet);", "", ]) lines.extend([ "const preview: Preview = {", " parameters: {", " controls: {", " matchers: {", " color: /(background|color)$/i,", " date: /Date$/i,", " },", " },", " backgrounds: {", " default: 'light',", " values: [", " { name: 'light', value: '#FFFFFF' },", " { name: 'dark', value: '#1F2937' },", " ],", " },", " },", "};", "", "export default preview;", ]) return "\n".join(lines) def generate_full_config( self, tokens: List[Dict[str, Any]], brand_title: str = "Design System", output_dir: Optional[str] = None, ) -> Dict[str, str]: """ Generate complete Storybook configuration files. Args: tokens: List of token dicts brand_title: Brand title output_dir: Optional directory to write files Returns: Dict mapping filenames to content """ # Generate theme theme = self.generate_from_tokens(tokens, brand_title) files = { "dss-theme.ts": self.generate_theme_file(theme, "ts"), "manager.ts": self.generate_manager_file(), "preview.ts": self.generate_preview_file(tokens), } # Write files if output_dir provided if output_dir: out_path = Path(output_dir) out_path.mkdir(parents=True, exist_ok=True) for filename, content in files.items(): (out_path / filename).write_text(content) return files