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
This commit is contained in:
Digital Production Factory
2025-12-09 18:45:48 -03:00
commit 276ed71f31
884 changed files with 373737 additions and 0 deletions

View File

@@ -0,0 +1,26 @@
"""
DSS Storybook Integration Module
Provides tools for:
- Scanning existing Storybook stories
- Generating stories from React components
- Creating themed Storybook configurations
- Syncing documentation with design tokens
"""
from .scanner import StorybookScanner, StoryInfo, StorybookConfig
from .generator import StoryGenerator, StoryTemplate
from .theme import ThemeGenerator, StorybookTheme
__all__ = [
# Scanner
"StorybookScanner",
"StoryInfo",
"StorybookConfig",
# Generator
"StoryGenerator",
"StoryTemplate",
# Theme
"ThemeGenerator",
"StorybookTheme",
]

View File

@@ -0,0 +1,433 @@
"""
Storybook Story Generator
Generates Storybook stories from React components.
"""
import re
from pathlib import Path
from typing import List, Dict, Any, Optional
from dataclasses import dataclass, field
from enum import Enum
class StoryTemplate(str, Enum):
"""Available story templates."""
CSF3 = "csf3" # Component Story Format 3 (latest)
CSF2 = "csf2" # Component Story Format 2
MDX = "mdx" # MDX format
@dataclass
class PropInfo:
"""Information about a component prop."""
name: str
type: str = "unknown"
required: bool = False
default_value: Optional[str] = None
description: str = ""
options: List[str] = field(default_factory=list) # For enum/union types
@dataclass
class ComponentMeta:
"""Metadata about a component for story generation."""
name: str
path: str
props: List[PropInfo] = field(default_factory=list)
description: str = ""
has_children: bool = False
class StoryGenerator:
"""
Generates Storybook stories from component information.
"""
def __init__(self, root_path: str):
self.root = Path(root_path).resolve()
async def generate_story(
self,
component_path: str,
template: StoryTemplate = StoryTemplate.CSF3,
include_variants: bool = True,
output_path: Optional[str] = None,
) -> str:
"""
Generate a Storybook story for a component.
Args:
component_path: Path to the component file
template: Story template format
include_variants: Generate variant stories
output_path: Optional path to write the story file
Returns:
Generated story code
"""
# Parse component
meta = await self._parse_component(component_path)
# Generate story based on template
if template == StoryTemplate.CSF3:
story = self._generate_csf3(meta, include_variants)
elif template == StoryTemplate.CSF2:
story = self._generate_csf2(meta, include_variants)
else:
story = self._generate_mdx(meta, include_variants)
# Write to file if output path provided
if output_path:
output = Path(output_path)
output.parent.mkdir(parents=True, exist_ok=True)
output.write_text(story)
return story
async def _parse_component(self, component_path: str) -> ComponentMeta:
"""Parse a React component to extract metadata."""
path = self.root / component_path if not Path(component_path).is_absolute() else Path(component_path)
content = path.read_text(encoding="utf-8", errors="ignore")
component_name = path.stem
props = []
# Extract props from interface/type
# interface ButtonProps { variant?: 'primary' | 'secondary'; ... }
props_pattern = re.compile(
r'(?:interface|type)\s+\w*Props\s*(?:=\s*)?\{([^}]+)\}',
re.DOTALL
)
props_match = props_pattern.search(content)
if props_match:
props_content = props_match.group(1)
# Parse each prop line
for line in props_content.split('\n'):
line = line.strip()
if not line or line.startswith('//'):
continue
# Match: propName?: type; or propName: type;
prop_match = re.match(
r'(\w+)(\?)?:\s*([^;/]+)',
line
)
if prop_match:
prop_name = prop_match.group(1)
is_optional = prop_match.group(2) == '?'
prop_type = prop_match.group(3).strip()
# Extract options from union types
options = []
if '|' in prop_type:
# 'primary' | 'secondary' | 'ghost'
options = [
o.strip().strip("'\"")
for o in prop_type.split('|')
if o.strip().startswith(("'", '"'))
]
props.append(PropInfo(
name=prop_name,
type=prop_type,
required=not is_optional,
options=options,
))
# Check if component uses children
has_children = 'children' in content.lower() and (
'React.ReactNode' in content or
'ReactNode' in content or
'{children}' in content
)
# Extract component description from JSDoc
description = ""
jsdoc_match = re.search(r'/\*\*\s*\n\s*\*\s*([^\n*]+)', content)
if jsdoc_match:
description = jsdoc_match.group(1).strip()
return ComponentMeta(
name=component_name,
path=component_path,
props=props,
description=description,
has_children=has_children,
)
def _generate_csf3(self, meta: ComponentMeta, include_variants: bool) -> str:
"""Generate CSF3 format story."""
lines = [
f"import type {{ Meta, StoryObj }} from '@storybook/react';",
f"import {{ {meta.name} }} from './{meta.name}';",
"",
f"const meta: Meta<typeof {meta.name}> = {{",
f" title: 'Components/{meta.name}',",
f" component: {meta.name},",
" parameters: {",
" layout: 'centered',",
" },",
" tags: ['autodocs'],",
]
# Add argTypes for props with options
arg_types = []
for prop in meta.props:
if prop.options:
arg_types.append(
f" {prop.name}: {{\n"
f" options: {prop.options},\n"
f" control: {{ type: 'select' }},\n"
f" }},"
)
if arg_types:
lines.append(" argTypes: {")
lines.extend(arg_types)
lines.append(" },")
lines.extend([
"};",
"",
"export default meta;",
f"type Story = StoryObj<typeof {meta.name}>;",
"",
])
# Generate default story
default_args = self._get_default_args(meta)
lines.extend([
"export const Default: Story = {",
" args: {",
])
for key, value in default_args.items():
lines.append(f" {key}: {value},")
lines.extend([
" },",
"};",
])
# Generate variant stories
if include_variants:
variant_prop = next(
(p for p in meta.props if p.name == 'variant' and p.options),
None
)
if variant_prop:
for variant in variant_prop.options:
story_name = variant.title().replace('-', '').replace('_', '')
lines.extend([
"",
f"export const {story_name}: Story = {{",
" args: {",
f" ...Default.args,",
f" variant: '{variant}',",
" },",
"};",
])
# Size variants
size_prop = next(
(p for p in meta.props if p.name == 'size' and p.options),
None
)
if size_prop:
for size in size_prop.options:
story_name = f"Size{size.title()}"
lines.extend([
"",
f"export const {story_name}: Story = {{",
" args: {",
f" ...Default.args,",
f" size: '{size}',",
" },",
"};",
])
# Disabled state
disabled_prop = next(
(p for p in meta.props if p.name == 'disabled'),
None
)
if disabled_prop:
lines.extend([
"",
"export const Disabled: Story = {",
" args: {",
" ...Default.args,",
" disabled: true,",
" },",
"};",
])
return "\n".join(lines)
def _generate_csf2(self, meta: ComponentMeta, include_variants: bool) -> str:
"""Generate CSF2 format story."""
lines = [
f"import React from 'react';",
f"import {{ {meta.name} }} from './{meta.name}';",
"",
"export default {",
f" title: 'Components/{meta.name}',",
f" component: {meta.name},",
"};",
"",
f"const Template = (args) => <{meta.name} {{...args}} />;",
"",
"export const Default = Template.bind({});",
"Default.args = {",
]
default_args = self._get_default_args(meta)
for key, value in default_args.items():
lines.append(f" {key}: {value},")
lines.append("};")
# Generate variant stories
if include_variants:
variant_prop = next(
(p for p in meta.props if p.name == 'variant' and p.options),
None
)
if variant_prop:
for variant in variant_prop.options:
story_name = variant.title().replace('-', '').replace('_', '')
lines.extend([
"",
f"export const {story_name} = Template.bind({{}});",
f"{story_name}.args = {{",
f" ...Default.args,",
f" variant: '{variant}',",
"};",
])
return "\n".join(lines)
def _generate_mdx(self, meta: ComponentMeta, include_variants: bool) -> str:
"""Generate MDX format story."""
lines = [
f"import {{ Meta, Story, Canvas, ArgsTable }} from '@storybook/blocks';",
f"import {{ {meta.name} }} from './{meta.name}';",
"",
f"<Meta title=\"Components/{meta.name}\" component={{{meta.name}}} />",
"",
f"# {meta.name}",
"",
]
if meta.description:
lines.extend([meta.description, ""])
lines.extend([
"## Default",
"",
"<Canvas>",
f" <Story name=\"Default\">",
f" <{meta.name}",
])
default_args = self._get_default_args(meta)
for key, value in default_args.items():
lines.append(f" {key}={value}")
lines.extend([
f" />",
" </Story>",
"</Canvas>",
"",
"## Props",
"",
f"<ArgsTable of={{{meta.name}}} />",
])
return "\n".join(lines)
def _get_default_args(self, meta: ComponentMeta) -> Dict[str, str]:
"""Get default args for a component."""
args = {}
for prop in meta.props:
if prop.name == 'children' and meta.has_children:
args['children'] = f"'{meta.name}'"
elif prop.name == 'variant' and prop.options:
args['variant'] = f"'{prop.options[0]}'"
elif prop.name == 'size' and prop.options:
args['size'] = f"'{prop.options[0]}'"
elif prop.name == 'disabled':
args['disabled'] = 'false'
elif prop.name == 'onClick':
args['onClick'] = '() => console.log("clicked")'
elif prop.required and prop.default_value:
args[prop.name] = prop.default_value
# Ensure children for button-like components
if meta.has_children and 'children' not in args:
args['children'] = f"'{meta.name}'"
return args
async def generate_stories_for_directory(
self,
directory: str,
template: StoryTemplate = StoryTemplate.CSF3,
dry_run: bool = True,
) -> List[Dict[str, str]]:
"""
Generate stories for all components in a directory.
Args:
directory: Path to component directory
template: Story template format
dry_run: If True, only return what would be generated
Returns:
List of dicts with component path and generated story
"""
results = []
dir_path = self.root / directory
if not dir_path.exists():
return results
# Find component files
for pattern in ['*.tsx', '*.jsx']:
for comp_path in dir_path.glob(pattern):
# Skip story files, test files, index files
if any(x in comp_path.name.lower() for x in ['.stories.', '.test.', '.spec.', 'index.']):
continue
# Skip non-component files (not PascalCase)
if not comp_path.stem[0].isupper():
continue
try:
rel_path = str(comp_path.relative_to(self.root))
story = await self.generate_story(rel_path, template)
# Determine story output path
story_path = comp_path.with_suffix('.stories.tsx')
result = {
'component': rel_path,
'story_path': str(story_path.relative_to(self.root)),
'story': story,
}
if not dry_run:
story_path.write_text(story)
result['written'] = True
results.append(result)
except Exception as e:
results.append({
'component': str(comp_path),
'error': str(e),
})
return results

357
tools/storybook/scanner.py Normal file
View File

@@ -0,0 +1,357 @@
"""
Storybook Scanner
Discovers and analyzes existing Storybook stories in a project.
"""
import re
import json
from pathlib import Path
from typing import List, Dict, Any, Optional, Set
from dataclasses import dataclass, field
@dataclass
class StoryInfo:
"""Information about a Storybook story."""
name: str # Story name (e.g., "Primary")
title: str # Story title (e.g., "Components/Button")
component: str # Component name
file_path: str # Path to story file
args: Dict[str, Any] = field(default_factory=dict) # Default args
parameters: Dict[str, Any] = field(default_factory=dict)
decorators: List[str] = field(default_factory=list)
tags: List[str] = field(default_factory=list)
def to_dict(self) -> Dict[str, Any]:
return {
"name": self.name,
"title": self.title,
"component": self.component,
"file_path": self.file_path,
"args": self.args,
"parameters": self.parameters,
"decorators": self.decorators,
"tags": self.tags,
}
@dataclass
class StorybookConfig:
"""Storybook configuration details."""
version: str = ""
framework: str = "" # react, vue, angular, etc.
builder: str = "" # vite, webpack5, etc.
addons: List[str] = field(default_factory=list)
stories_patterns: List[str] = field(default_factory=list)
static_dirs: List[str] = field(default_factory=list)
config_path: str = ""
def to_dict(self) -> Dict[str, Any]:
return {
"version": self.version,
"framework": self.framework,
"builder": self.builder,
"addons": self.addons,
"stories_patterns": self.stories_patterns,
"static_dirs": self.static_dirs,
"config_path": self.config_path,
}
class StorybookScanner:
"""
Scans a project for Storybook configuration and stories.
"""
# Common story file patterns
STORY_PATTERNS = [
'*.stories.tsx',
'*.stories.ts',
'*.stories.jsx',
'*.stories.js',
'*.stories.mdx',
]
def __init__(self, root_path: str):
self.root = Path(root_path).resolve()
async def scan(self) -> Dict[str, Any]:
"""
Perform full Storybook scan.
Returns:
Dict with configuration and story inventory
"""
config = await self._find_config()
stories = await self._find_stories()
# Group stories by component
by_component: Dict[str, List[StoryInfo]] = {}
for story in stories:
if story.component not in by_component:
by_component[story.component] = []
by_component[story.component].append(story)
return {
"config": config.to_dict() if config else None,
"stories_count": len(stories),
"components_with_stories": len(by_component),
"stories": [s.to_dict() for s in stories],
"by_component": {
comp: [s.to_dict() for s in stories_list]
for comp, stories_list in by_component.items()
},
}
async def _find_config(self) -> Optional[StorybookConfig]:
"""Find and parse Storybook configuration."""
# Look for .storybook directory
storybook_dir = self.root / ".storybook"
if not storybook_dir.exists():
# Try alternative locations
for alt in ["storybook", ".storybook"]:
alt_path = self.root / alt
if alt_path.exists():
storybook_dir = alt_path
break
else:
return None
config = StorybookConfig(config_path=str(storybook_dir))
# Parse main.js/ts
for main_file in ["main.ts", "main.js", "main.mjs"]:
main_path = storybook_dir / main_file
if main_path.exists():
await self._parse_main_config(main_path, config)
break
# Check package.json for Storybook version
pkg_json = self.root / "package.json"
if pkg_json.exists():
try:
pkg = json.loads(pkg_json.read_text())
deps = {**pkg.get("dependencies", {}), **pkg.get("devDependencies", {})}
# Get Storybook version
for pkg_name in ["@storybook/react", "@storybook/vue3", "@storybook/angular"]:
if pkg_name in deps:
config.version = deps[pkg_name].lstrip("^~")
config.framework = pkg_name.split("/")[1]
break
# Get builder
if "@storybook/builder-vite" in deps:
config.builder = "vite"
elif "@storybook/builder-webpack5" in deps:
config.builder = "webpack5"
# Get addons
config.addons = [
pkg for pkg in deps.keys()
if pkg.startswith("@storybook/addon-")
]
except (json.JSONDecodeError, KeyError):
pass
return config
async def _parse_main_config(self, main_path: Path, config: StorybookConfig) -> None:
"""Parse main.js/ts for configuration."""
try:
content = main_path.read_text(encoding="utf-8")
# Extract stories patterns
stories_match = re.search(
r'stories\s*:\s*\[([^\]]+)\]',
content,
re.DOTALL
)
if stories_match:
patterns_str = stories_match.group(1)
patterns = re.findall(r'["\']([^"\']+)["\']', patterns_str)
config.stories_patterns = patterns
# Extract static dirs
static_match = re.search(
r'staticDirs\s*:\s*\[([^\]]+)\]',
content,
re.DOTALL
)
if static_match:
dirs_str = static_match.group(1)
dirs = re.findall(r'["\']([^"\']+)["\']', dirs_str)
config.static_dirs = dirs
# Extract framework
framework_match = re.search(
r'framework\s*:\s*["\'](@storybook/[^"\']+)["\']',
content
)
if framework_match:
config.framework = framework_match.group(1)
except Exception:
pass
async def _find_stories(self) -> List[StoryInfo]:
"""Find all story files in the project."""
stories = []
skip_dirs = {'node_modules', '.git', 'dist', 'build'}
for pattern in self.STORY_PATTERNS:
for story_path in self.root.rglob(pattern):
if any(skip in story_path.parts for skip in skip_dirs):
continue
try:
file_stories = await self._parse_story_file(story_path)
stories.extend(file_stories)
except Exception:
continue
return stories
async def _parse_story_file(self, story_path: Path) -> List[StoryInfo]:
"""Parse a story file to extract story information."""
content = story_path.read_text(encoding="utf-8", errors="ignore")
rel_path = str(story_path.relative_to(self.root))
stories = []
# Extract meta/default export
title = ""
component = ""
# CSF3 format: const meta = { title: '...', component: ... }
meta_match = re.search(
r'(?:const\s+meta|export\s+default)\s*[=:]\s*\{([^}]+)\}',
content,
re.DOTALL
)
if meta_match:
meta_content = meta_match.group(1)
title_match = re.search(r'title\s*:\s*["\']([^"\']+)["\']', meta_content)
if title_match:
title = title_match.group(1)
comp_match = re.search(r'component\s*:\s*(\w+)', meta_content)
if comp_match:
component = comp_match.group(1)
# If no title, derive from file path
if not title:
# Convert path to title (e.g., src/components/Button.stories.tsx -> Components/Button)
parts = story_path.stem.replace('.stories', '').split('/')
title = '/'.join(p.title() for p in parts[-2:] if p)
if not component:
component = story_path.stem.replace('.stories', '')
# Find exported stories (CSF3 format)
# export const Primary: Story = { ... }
story_pattern = re.compile(
r'export\s+const\s+(\w+)\s*(?::\s*\w+)?\s*=\s*\{([^}]*)\}',
re.DOTALL
)
for match in story_pattern.finditer(content):
story_name = match.group(1)
story_content = match.group(2)
# Skip meta export
if story_name.lower() in ['meta', 'default']:
continue
# Parse args
args = {}
args_match = re.search(r'args\s*:\s*\{([^}]*)\}', story_content)
if args_match:
args_str = args_match.group(1)
# Simple key-value extraction
for kv_match in re.finditer(r'(\w+)\s*:\s*["\']?([^,\n"\']+)["\']?', args_str):
args[kv_match.group(1)] = kv_match.group(2).strip()
stories.append(StoryInfo(
name=story_name,
title=title,
component=component,
file_path=rel_path,
args=args,
))
# Also check for older CSF2 format
# export const Primary = Template.bind({})
csf2_pattern = re.compile(
r'export\s+const\s+(\w+)\s*=\s*Template\.bind\(\{\}\)'
)
for match in csf2_pattern.finditer(content):
story_name = match.group(1)
if not any(s.name == story_name for s in stories):
stories.append(StoryInfo(
name=story_name,
title=title,
component=component,
file_path=rel_path,
))
return stories
async def get_components_without_stories(
self,
component_files: List[str]
) -> List[str]:
"""
Find components that don't have Storybook stories.
Args:
component_files: List of component file paths
Returns:
List of component paths without stories
"""
# Get all components with stories
result = await self.scan()
components_with_stories = set(result.get("by_component", {}).keys())
# Find components without stories
without_stories = []
for comp_path in component_files:
# Extract component name from path
comp_name = Path(comp_path).stem
if comp_name not in components_with_stories:
without_stories.append(comp_path)
return without_stories
async def get_story_coverage(self) -> Dict[str, Any]:
"""
Calculate story coverage statistics.
Returns:
Coverage statistics including counts and percentages
"""
result = await self.scan()
stories_count = result.get("stories_count", 0)
components_count = result.get("components_with_stories", 0)
# Count stories per component
by_component = result.get("by_component", {})
stories_per_component = {
comp: len(stories) for comp, stories in by_component.items()
}
avg_stories = (
sum(stories_per_component.values()) / len(stories_per_component)
if stories_per_component else 0
)
return {
"total_stories": stories_count,
"components_covered": components_count,
"average_stories_per_component": round(avg_stories, 1),
"stories_per_component": stories_per_component,
}

374
tools/storybook/theme.py Normal file
View File

@@ -0,0 +1,374 @@
"""
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