#!/usr/bin/env python3
"""
DSS Storybook Generator
Generates Storybook stories from DSS tokens and component registry.
Hierarchy:
1. Primitives (Foundation) - colors, spacing, typography, radius, shadows
2. Semantic Tokens (Design Tokens) - from skin
3. Components - from shadcn registry
Usage: python3 scripts/generate-storybook.py [--output PATH] [--skin SKIN]
Default output: admin-ui/src/stories/
"""
import sys
import os
import json
import argparse
from pathlib import Path
from datetime import datetime
from typing import Dict, Any, List
DSS_ROOT = Path(__file__).parent.parent
DSS_DATA = DSS_ROOT / ".dss"
def load_json(path: Path) -> dict:
"""Load JSON file, return empty dict if not found"""
if not path.exists():
return {}
with open(path) as f:
return json.load(f)
def ensure_dir(path: Path):
"""Ensure directory exists"""
path.mkdir(parents=True, exist_ok=True)
def generate_color_primitives_story(primitives: dict, output_dir: Path):
"""Generate story for color primitives (full Tailwind palette)"""
colors = primitives.get("color", {})
if not colors:
return
# Build organized sections
base_section = ""
neutral_section = ""
semantic_section = ""
# Base colors
base = colors.get("base", {})
base_swatches = []
for name, data in base.items():
if name.startswith("_"):
continue
if isinstance(data, dict) and "value" in data:
border = "border: 1px solid #e5e7eb;" if data["value"] in ["#ffffff", "transparent"] else ""
base_swatches.append(f'''
''')
if base_swatches:
base_section = f'''
Base
{''.join(base_swatches)}
'''
# Neutral scales
neutrals = colors.get("neutral", {})
neutral_palettes = []
for scale_name, scale in neutrals.items():
if scale_name.startswith("_"):
continue
if isinstance(scale, dict):
shades = []
for shade, data in sorted(scale.items(), key=lambda x: int(x[0]) if x[0].isdigit() else 0):
if isinstance(data, dict) and "value" in data:
text_color = "#000" if int(shade) < 500 else "#fff"
shades.append(f'''
{shade}
''')
if shades:
neutral_palettes.append(f'''
{scale_name}
{''.join(shades)}
''')
if neutral_palettes:
neutral_section = f'''
Neutral Scales
{''.join(neutral_palettes)}
'''
# Semantic scales
semantics = colors.get("semantic", {})
semantic_palettes = []
for scale_name, scale in semantics.items():
if scale_name.startswith("_"):
continue
if isinstance(scale, dict):
shades = []
for shade, data in sorted(scale.items(), key=lambda x: int(x[0]) if x[0].isdigit() else 0):
if isinstance(data, dict) and "value" in data:
text_color = "#000" if int(shade) < 500 else "#fff"
shades.append(f'''
{shade}
''')
if shades:
semantic_palettes.append(f'''
{scale_name}
{''.join(shades)}
''')
if semantic_palettes:
semantic_section = f'''
Semantic Scales
{''.join(semantic_palettes)}
'''
story = f'''/**
* Color Primitives - Foundation
* Full Tailwind color palette organized by category
* @generated {datetime.now().isoformat()}
*/
export default {{
title: 'Foundation/Colors/Primitives',
tags: ['autodocs'],
parameters: {{
docs: {{
description: {{
component: 'Core color primitives from Tailwind palette. Organized into Base, Neutral, and Semantic scales.'
}}
}}
}}
}};
const styles = `
.color-container {{ font-family: system-ui, sans-serif; }}
.color-section {{ margin-bottom: 3rem; }}
.color-section h2 {{ font-size: 1.25rem; border-bottom: 1px solid #e5e7eb; padding-bottom: 0.5rem; margin-bottom: 1rem; }}
.swatch-row {{ display: flex; flex-wrap: wrap; gap: 1rem; }}
.color-swatch {{ text-align: center; }}
.swatch {{ width: 80px; height: 80px; border-radius: 8px; margin-bottom: 0.5rem; }}
.label {{ font-weight: 500; font-size: 0.875rem; }}
.value {{ font-family: monospace; font-size: 0.75rem; color: #6b7280; }}
.palette-grid {{ display: flex; flex-wrap: wrap; gap: 2rem; }}
.color-palette {{ min-width: 140px; }}
.color-palette h3 {{ margin: 0 0 0.5rem; text-transform: capitalize; font-size: 0.875rem; font-weight: 600; }}
.shades {{ display: flex; flex-direction: column; border-radius: 8px; overflow: hidden; }}
.shade {{ padding: 0.5rem 0.75rem; font-family: monospace; font-size: 0.65rem; }}
`;
export const AllColors = {{
render: () => `
{base_section}
{neutral_section}
{semantic_section}
`
}};
'''
with open(output_dir / "ColorPrimitives.stories.js", "w") as f:
f.write(story)
print(f" [OK] ColorPrimitives.stories.js")
def generate_spacing_story(primitives: dict, output_dir: Path):
"""Generate story for spacing primitives"""
spacing = primitives.get("spacing", {})
if not spacing:
return
items = []
for name, data in sorted(spacing.items(), key=lambda x: float(x[0]) if x[0].replace('.', '').isdigit() else -1):
if name.startswith("_"):
continue
if isinstance(data, dict) and "value" in data:
comment = data.get("_comment", "")
items.append(f'''
''')
story = f'''/**
* Spacing Primitives - Foundation
* @generated {datetime.now().isoformat()}
*/
export default {{
title: 'Foundation/Spacing',
tags: ['autodocs'],
parameters: {{
docs: {{
description: {{
component: 'Spacing scale based on 4px grid. Use for margins, padding, and gaps.'
}}
}}
}}
}};
const styles = `
.spacing-grid {{ display: flex; flex-direction: column; gap: 0.25rem; max-width: 600px; }}
.spacing-item {{ display: flex; align-items: center; gap: 1rem; }}
.bar {{ height: 20px; background: var(--color-primary, #18181b); border-radius: 2px; min-width: 2px; }}
.info {{ display: flex; gap: 1rem; font-family: monospace; font-size: 0.75rem; }}
.name {{ font-weight: 600; min-width: 32px; }}
.value {{ color: #6b7280; min-width: 80px; }}
.comment {{ color: #9ca3af; font-size: 0.65rem; }}
`;
export const SpacingScale = {{
render: () => `
{''.join(items)}
`
}};
'''
with open(output_dir / "Spacing.stories.js", "w") as f:
f.write(story)
print(f" [OK] Spacing.stories.js")
def generate_typography_story(primitives: dict, resolved: dict, output_dir: Path):
"""Generate story for typography tokens"""
font = primitives.get("font", {})
typography = resolved.get("typography", {})
# Font families
families = font.get("family", {})
family_samples = []
for name, data in families.items():
if name.startswith("_"):
continue
if isinstance(data, dict) and "value" in data:
family_samples.append(f'''
The quick brown fox jumps over the lazy dog
{name}
{data['value'][:40]}...
''')
# Font sizes
sizes = font.get("size", {})
size_samples = []
for name, data in sorted(sizes.items(), key=lambda x: float(x[0].replace('xl', '').replace('x', '')) if x[0].replace('xl', '').replace('x', '').replace('.', '').isdigit() else 0):
if name.startswith("_"):
continue
if isinstance(data, dict) and "value" in data:
size_samples.append(f'''
Aa
{name}
{data['value']}
''')
# Font weights
weights = font.get("weight", {})
weight_samples = []
for name, data in weights.items():
if name.startswith("_"):
continue
if isinstance(data, dict) and "value" in data:
weight_samples.append(f'''
Aa
{name}
{data['value']}
''')
# Typography styles from resolved tokens
style_samples = []
for name, props in typography.items():
if isinstance(props, dict):
font_family = props.get("font-family", {}).get("value", "Inter")
font_size = props.get("font-size", {}).get("value", "16px")
font_weight = props.get("font-weight", {}).get("value", 400)
line_height = props.get("line-height", {}).get("value", "1.5")
style_samples.append(f'''
The quick brown fox
{name}
{font_size} / {font_weight}
''')
story = f'''/**
* Typography - Foundation
* Font families, sizes, weights, and composed styles
* @generated {datetime.now().isoformat()}
*/
export default {{
title: 'Foundation/Typography',
tags: ['autodocs'],
parameters: {{
docs: {{
description: {{
component: 'Typography primitives and composed text styles.'
}}
}}
}}
}};
const styles = `
.typo-container {{ font-family: system-ui, sans-serif; }}
.section {{ margin-bottom: 3rem; }}
.section h2 {{ font-size: 1.25rem; border-bottom: 1px solid #e5e7eb; padding-bottom: 0.5rem; margin-bottom: 1rem; }}
.font-sample {{ margin-bottom: 1.5rem; }}
.sample-text {{ font-size: 1.5rem; margin-bottom: 0.25rem; }}
.meta {{ font-size: 0.75rem; font-family: monospace; }}
.meta .name {{ font-weight: 600; margin-right: 1rem; }}
.meta .value {{ color: #6b7280; }}
.size-grid, .weight-grid {{ display: flex; flex-wrap: wrap; gap: 1.5rem; }}
.size-sample, .weight-sample {{ text-align: center; min-width: 60px; }}
.size-sample .text {{ display: block; margin-bottom: 0.25rem; }}
.weight-sample .text {{ display: block; margin-bottom: 0.25rem; font-size: 1.5rem; }}
.name {{ font-size: 0.75rem; font-weight: 500; display: block; }}
.value {{ font-size: 0.65rem; color: #6b7280; font-family: monospace; }}
.props {{ color: #6b7280; font-family: monospace; }}
.style-sample {{ margin-bottom: 1rem; border-bottom: 1px solid #f3f4f6; padding-bottom: 1rem; }}
.style-sample .text {{ margin-bottom: 0.25rem; color: var(--color-foreground, #18181b); }}
`;
export const FontFamilies = {{
render: () => `
Font Families
{''.join(family_samples)}
`
}};
export const FontSizes = {{
render: () => `
Font Sizes
{''.join(size_samples)}
`
}};
export const FontWeights = {{
render: () => `
Font Weights
{''.join(weight_samples)}
`
}};
export const TextStyles = {{
render: () => `
Composed Text Styles
{''.join(style_samples)}
`
}};
'''
with open(output_dir / "Typography.stories.js", "w") as f:
f.write(story)
print(f" [OK] Typography.stories.js")
def generate_shadows_story(primitives: dict, resolved: dict, output_dir: Path):
"""Generate story for shadow tokens"""
shadows = primitives.get("shadow", {})
effects = resolved.get("effect", {})
# Combine both sources
all_shadows = {}
for name, data in shadows.items():
if name.startswith("_"):
continue
if isinstance(data, dict) and "value" in data:
all_shadows[name] = data["value"]
for name, data in effects.items():
if isinstance(data, dict) and "value" in data and "shadow" in name:
all_shadows[name] = data["value"]
items = []
for name, value in all_shadows.items():
items.append(f'''
{name}
{value[:50]}{"..." if len(value) > 50 else ""}
''')
story = f'''/**
* Shadows - Foundation
* Box shadow scale
* @generated {datetime.now().isoformat()}
*/
export default {{
title: 'Foundation/Effects/Shadows',
tags: ['autodocs'],
parameters: {{
docs: {{
description: {{
component: 'Box shadow tokens for elevation and depth.'
}}
}}
}}
}};
const styles = `
.shadows-grid {{ display: flex; flex-wrap: wrap; gap: 2rem; padding: 2rem; background: #f9fafb; }}
.shadow-card {{ text-align: center; }}
.box {{ width: 120px; height: 80px; background: white; border-radius: 8px; margin-bottom: 0.5rem; }}
.name {{ font-size: 0.75rem; font-weight: 500; }}
.value {{ font-size: 0.6rem; color: #6b7280; font-family: monospace; max-width: 120px; word-wrap: break-word; }}
`;
export const AllShadows = {{
render: () => `
{''.join(items)}
`
}};
'''
with open(output_dir / "Shadows.stories.js", "w") as f:
f.write(story)
print(f" [OK] Shadows.stories.js")
def generate_radius_story(primitives: dict, output_dir: Path):
"""Generate story for border radius tokens"""
radius = primitives.get("radius", {})
if not radius:
return
items = []
for name, data in radius.items():
if name.startswith("_"):
continue
if isinstance(data, dict) and "value" in data:
comment = data.get("_comment", "")
items.append(f'''
''')
story = f'''/**
* Border Radius - Foundation
* @generated {datetime.now().isoformat()}
*/
export default {{
title: 'Foundation/Radius',
tags: ['autodocs'],
parameters: {{
docs: {{
description: {{
component: 'Border radius scale for consistent rounded corners.'
}}
}}
}}
}};
const styles = `
.radius-grid {{ display: flex; flex-wrap: wrap; gap: 2rem; }}
.radius-item {{ text-align: center; }}
.box {{ width: 80px; height: 80px; background: var(--color-primary, #18181b); margin-bottom: 0.5rem; }}
.name {{ font-weight: 500; font-size: 0.875rem; }}
.value {{ font-family: monospace; font-size: 0.75rem; color: #6b7280; }}
.comment {{ font-size: 0.65rem; color: #9ca3af; }}
`;
export const RadiusScale = {{
render: () => `
{''.join(items)}
`
}};
'''
with open(output_dir / "Radius.stories.js", "w") as f:
f.write(story)
print(f" [OK] Radius.stories.js")
def generate_semantic_colors_story(resolved: dict, output_dir: Path):
"""Generate story for semantic color tokens"""
colors = resolved.get("color", {})
if not colors:
return
# Group by semantic category
groups = {
"Surface": [],
"Primary": [],
"Secondary": [],
"Accent": [],
"Muted": [],
"Destructive": [],
"Other": []
}
for name, data in colors.items():
if isinstance(data, dict) and "value" in data:
value = data["value"]
comment = data.get("comment", "")
card = f'''
'''
if any(x in name for x in ["background", "foreground", "card", "popover"]):
groups["Surface"].append(card)
elif "primary" in name:
groups["Primary"].append(card)
elif "secondary" in name:
groups["Secondary"].append(card)
elif "accent" in name:
groups["Accent"].append(card)
elif "muted" in name:
groups["Muted"].append(card)
elif "destructive" in name:
groups["Destructive"].append(card)
else:
groups["Other"].append(card)
sections = []
for group_name, cards in groups.items():
if cards:
sections.append(f'''
{group_name}
{''.join(cards)}
''')
story = f'''/**
* Semantic Colors - Design Tokens
* Resolved color tokens for light theme
* @generated {datetime.now().isoformat()}
*/
export default {{
title: 'Tokens/Semantic Colors',
tags: ['autodocs'],
parameters: {{
docs: {{
description: {{
component: 'Semantic color tokens mapped from primitives. Use these in components via CSS variables.'
}}
}}
}}
}};
const styles = `
.semantic-colors {{ display: flex; flex-direction: column; gap: 2rem; }}
.token-group h3 {{ margin: 0 0 1rem; font-size: 1rem; border-bottom: 1px solid #e5e7eb; padding-bottom: 0.5rem; }}
.token-row {{ display: flex; flex-wrap: wrap; gap: 1rem; }}
.token-card {{ text-align: center; }}
.swatch {{ width: 80px; height: 60px; border-radius: 8px; border: 1px solid #e5e7eb; margin-bottom: 0.5rem; }}
.name {{ font-size: 0.75rem; font-weight: 500; }}
.value {{ font-family: monospace; font-size: 0.65rem; color: #6b7280; }}
.comment {{ font-size: 0.6rem; color: #9ca3af; max-width: 80px; }}
`;
export const LightTheme = {{
render: () => `
{''.join(sections)}
`
}};
'''
with open(output_dir / "SemanticColors.stories.js", "w") as f:
f.write(story)
print(f" [OK] SemanticColors.stories.js")
def generate_component_stories(registry: dict, output_dir: Path):
"""Generate stories for shadcn components from registry"""
components = registry.get("components", {})
categories = registry.get("categories", {})
if not components:
print(" [SKIP] No components in registry")
return
# Generate a story file per category
for cat_id, cat_data in categories.items():
cat_name = cat_data.get("name", cat_id.title())
cat_desc = cat_data.get("description", "")
cat_components = cat_data.get("components", [])
component_cards = []
for comp_id in cat_components:
comp = components.get(comp_id, {})
if not comp:
continue
name = comp.get("name", comp_id)
desc = comp.get("description", "")
variants = comp.get("variants", {})
radix = comp.get("radixPrimitive", "")
deps = comp.get("dependencies", [])
variant_badges = ""
if variants:
for var_name, var_values in variants.items():
badges = " ".join([f'{v}' for v in var_values[:4]])
variant_badges += f'{var_name}: {badges}
'
radix_badge = f'{radix}' if radix else ""
deps_text = ", ".join(deps[:3]) if deps else ""
component_cards.append(f'''
{desc}
{variant_badges}
{f'
deps: {deps_text}
' if deps_text else ''}
''')
story = f'''/**
* {cat_name}
* {cat_desc}
* @generated {datetime.now().isoformat()}
*/
export default {{
title: 'Components/{cat_name}',
tags: ['autodocs'],
parameters: {{
docs: {{
description: {{
component: '{cat_desc}'
}}
}}
}}
}};
const styles = `
.component-grid {{ display: grid; grid-template-columns: repeat(auto-fill, minmax(280px, 1fr)); gap: 1.5rem; }}
.component-card {{
border: 1px solid #e5e7eb;
border-radius: 8px;
padding: 1rem;
background: white;
}}
.card-header {{
display: flex;
align-items: center;
gap: 0.5rem;
margin-bottom: 0.5rem;
}}
.card-header h3 {{ margin: 0; font-size: 1rem; }}
.radix-badge {{
font-size: 0.6rem;
background: #dbeafe;
color: #1d4ed8;
padding: 0.125rem 0.375rem;
border-radius: 4px;
font-family: monospace;
}}
.description {{ font-size: 0.8rem; color: #6b7280; margin: 0 0 0.75rem; }}
.variants {{ margin-bottom: 0.5rem; }}
.variant-row {{ font-size: 0.7rem; margin-bottom: 0.25rem; }}
.var-name {{ font-weight: 500; margin-right: 0.25rem; }}
.badge {{
display: inline-block;
background: #f3f4f6;
padding: 0.125rem 0.375rem;
border-radius: 4px;
margin-right: 0.25rem;
font-family: monospace;
font-size: 0.65rem;
}}
.deps {{ font-size: 0.65rem; color: #9ca3af; font-family: monospace; }}
`;
export const Overview = {{
name: 'Component Catalog',
render: () => `
{''.join(component_cards)}
`
}};
'''
# Create safe filename
filename = f"Components{cat_name.replace(' ', '')}.stories.js"
with open(output_dir / filename, "w") as f:
f.write(story)
print(f" [OK] {filename} ({len(cat_components)} components)")
def generate_overview_story(output_dir: Path):
"""Generate overview/introduction story"""
story = f'''/**
* Design System Overview
* @generated {datetime.now().isoformat()}
*/
export default {{
title: 'Overview',
tags: ['autodocs'],
parameters: {{
docs: {{
description: {{
component: 'DSS Design System - Token documentation and component library'
}}
}}
}}
}};
const styles = `
.overview {{ max-width: 800px; font-family: system-ui, sans-serif; }}
.overview h1 {{ font-size: 2rem; margin-bottom: 0.5rem; }}
.overview .subtitle {{ color: #6b7280; margin-bottom: 2rem; }}
.overview h2 {{ font-size: 1.25rem; margin-top: 2rem; border-bottom: 1px solid #e5e7eb; padding-bottom: 0.5rem; }}
.overview ul {{ padding-left: 1.5rem; }}
.overview li {{ margin: 0.5rem 0; }}
.overview code {{ background: #f3f4f6; padding: 0.125rem 0.375rem; border-radius: 4px; font-size: 0.875rem; }}
.layer {{ display: flex; align-items: center; gap: 0.5rem; padding: 0.75rem; background: #f9fafb; border-radius: 8px; margin: 0.5rem 0; }}
.layer-num {{ width: 24px; height: 24px; background: #18181b; color: white; border-radius: 50%; display: flex; align-items: center; justify-content: center; font-size: 0.75rem; font-weight: 600; }}
.stats {{ display: flex; gap: 1rem; margin: 1rem 0; }}
.stat {{ background: #f9fafb; padding: 1rem; border-radius: 8px; text-align: center; }}
.stat-value {{ font-size: 2rem; font-weight: 700; color: #18181b; }}
.stat-label {{ font-size: 0.75rem; color: #6b7280; }}
`;
export const Introduction = {{
render: () => `
DSS Design System
Token-driven design system with 3-layer architecture
Architecture
1
Core Primitives - Raw Tailwind values (colors, spacing, fonts)
2
Skin - Semantic mapping (primary, secondary, etc.)
3
Theme - Brand overrides
Navigation
- Foundation - Core primitives (colors, spacing, typography, radius, shadows)
- Tokens - Semantic design tokens from skin
- Components - 59 shadcn/ui components organized by category
Component Categories
- Form - Button, Input, Select, Checkbox, etc.
- Data Display - Table, Badge, Avatar, Chart, etc.
- Feedback - Alert, Toast, Progress, Spinner, etc.
- Navigation - Tabs, Breadcrumb, Sidebar, etc.
- Overlay - Dialog, Sheet, Dropdown, Tooltip, etc.
- Layout - Card, Separator, Scroll Area, etc.
Usage
Import tokens CSS in your project:
import '.dss/data/_system/themes/tokens.css';
Use CSS variables in your styles:
color: var(--color-primary);
background: var(--color-background);
`
}};
'''
with open(output_dir / "Overview.stories.js", "w") as f:
f.write(story)
print(f" [OK] Overview.stories.js")
def main():
parser = argparse.ArgumentParser(description="Generate Storybook stories from DSS tokens")
parser.add_argument("--output", default="admin-ui/src/stories",
help="Output directory for stories")
parser.add_argument("--skin", default="shadcn", help="Skin to use")
args = parser.parse_args()
print("=" * 60)
print("DSS STORYBOOK GENERATOR")
print("=" * 60)
print("")
# Determine output directory
output_dir = Path(args.output)
if not output_dir.is_absolute():
output_dir = DSS_ROOT / output_dir
ensure_dir(output_dir)
print(f"[INFO] Output: {output_dir}")
# Load token sources
primitives = load_json(DSS_DATA / "core" / "primitives.json")
skin = load_json(DSS_DATA / "skins" / args.skin / "tokens.json")
resolved = load_json(DSS_DATA / "data" / "_system" / "tokens" / "tokens.json")
registry = load_json(DSS_DATA / "components" / "shadcn-registry.json")
print(f"[INFO] Core primitives: {len(primitives)} categories")
print(f"[INFO] Skin: {args.skin}")
print(f"[INFO] Resolved tokens: {sum(len(v) if isinstance(v, dict) else 0 for v in resolved.values())} tokens")
print(f"[INFO] Component registry: {len(registry.get('components', {}))} components")
print("")
print("[STEP] Generating Foundation stories...")
generate_overview_story(output_dir)
generate_color_primitives_story(primitives, output_dir)
generate_spacing_story(primitives, output_dir)
generate_typography_story(primitives, resolved, output_dir)
generate_radius_story(primitives, output_dir)
generate_shadows_story(primitives, resolved, output_dir)
print("")
print("[STEP] Generating Token stories...")
generate_semantic_colors_story(resolved, output_dir)
print("")
print("[STEP] Generating Component stories...")
generate_component_stories(registry, output_dir)
print("")
story_count = len(list(output_dir.glob("*.stories.js")))
print(f"[OK] Generated {story_count} story files")
print(f"[OK] Run: cd admin-ui && npm run storybook")
if __name__ == "__main__":
main()