diff --git a/.claude/commands/create-spec.md b/.claude/commands/create-spec.md index f0555d24..e7a11249 100644 --- a/.claude/commands/create-spec.md +++ b/.claude/commands/create-spec.md @@ -95,7 +95,64 @@ Ask the user about their involvement preference: **For Detailed Mode users**, ask specific tech questions about frontend, backend, database, etc. -### Phase 3b: Database Requirements (MANDATORY) +### Phase 3b: UI Component Library (OPTIONAL) + +Ask the user which UI component library they prefer. This step helps configure automated component generation. + +> "Would you like to use a UI component library for faster development? +> +> 1. **shadcn/ui (Recommended for React)** - Beautiful, accessible components with Radix primitives +> - Copy-paste approach (you own the code) +> - MCP-enabled: Agent can generate components automatically +> +> 2. **Ark UI (Recommended for multi-framework)** - Headless primitives for any framework +> - Works with React, Vue, Solid, Svelte +> - MCP-enabled: Agent can generate components automatically +> +> 3. **Radix UI** - Low-level headless primitives +> - Requires manual styling +> - Uses frontend-design skill for component creation +> +> 4. **None/Custom** - Build components from scratch +> - Full control, more development time +> - Uses frontend-design skill for component creation +> +> 5. **Skip this question** - Use default (no library)" + +**Framework validation:** If user chooses shadcn/ui but their framework isn't React, warn them and suggest Ark UI instead. + +### Phase 3c: Visual Style (OPTIONAL) + +Ask the user which visual style they prefer. This step configures design tokens for consistent styling. + +> "What visual style do you prefer for your app? +> +> 1. **Modern/Clean (Recommended)** - Minimal, professional design +> - Uses library defaults, no custom tokens needed +> +> 2. **Neobrutalism** - Bold colors, hard shadows, no border-radius +> - High contrast, playful aesthetic +> - Generates design tokens: 4px borders, offset shadows +> +> 3. **Glassmorphism** - Frosted glass effects, blur, transparency +> - Subtle gradients, floating elements +> - Generates design tokens: backdrop-blur, low opacity backgrounds +> +> 4. **Retro/Arcade** - Pixel-art inspired, vibrant neons +> - 8-bit aesthetic, chunky borders +> - Generates design tokens: sharp corners, neon gradients +> +> 5. **Custom** - Define your own design tokens +> - Maximum flexibility +> +> 6. **Skip this question** - Use default (Modern/Clean)" + +**Behavior:** +- If user skips or chooses "Modern/Clean", no design tokens file is generated +- Other styles generate `.autocoder/design-tokens.json` with appropriate presets +- The coding agent will apply these tokens when building components + +### Phase 3d: Database Requirements (MANDATORY) **Always ask this question regardless of mode:** @@ -236,7 +293,7 @@ These are just reference points - your actual count should come from the require **MANDATORY: Infrastructure Features** -If the app requires a database (Phase 3b answer was "Yes" or "Not sure"), you MUST include 5 Infrastructure features (indices 0-4): +If the app requires a database (Phase 3d answer was "Yes" or "Not sure"), you MUST include 5 Infrastructure features (indices 0-4): 1. Database connection established 2. Database schema applied correctly 3. Data persists across server restart @@ -457,6 +514,19 @@ Create a new file using this XML structure: [Font preferences] + + + + [shadcn-ui | ark-ui | radix-ui | none] + [react | vue | solid | svelte] + [true if shadcn-ui or ark-ui, false otherwise] + + + + + + [.autocoder/design-tokens.json if style != default, empty otherwise] + diff --git a/.claude/templates/app_spec.template.txt b/.claude/templates/app_spec.template.txt index ebdfb856..ad3632bb 100644 --- a/.claude/templates/app_spec.template.txt +++ b/.claude/templates/app_spec.template.txt @@ -216,6 +216,44 @@ - Loading spinners - Skeleton loaders + + + + none + react + false + + + + + + + diff --git a/.claude/templates/coding_prompt.template.md b/.claude/templates/coding_prompt.template.md index c8d3ba6b..df5243d1 100644 --- a/.claude/templates/coding_prompt.template.md +++ b/.claude/templates/coding_prompt.template.md @@ -111,6 +111,7 @@ Use browser automation tools: **Complete ALL applicable checks before marking any feature as passing:** +- **UI & Design:** Used MCP tools for component generation when available (`mcp__ui_components__*`); components follow the project's visual style (check `app_spec.txt`); if design tokens exist (`.autoforge/design-tokens.json`), styling matches; components are accessible (ARIA labels, keyboard nav); responsive at desktop (1920px), tablet (768px), mobile (375px) - **Security:** Feature respects role permissions; unauthenticated access blocked; API checks auth (401/403); no cross-user data leaks via URL manipulation - **Real Data:** Create unique test data via UI, verify it appears, refresh to confirm persistence, delete and verify removal. No unexplained data (indicates mocks). Dashboard counts reflect real numbers - **Mock Data Grep:** Run STEP 5.6 grep checks - no hits in src/ (excluding tests). No globalThis, devStore, or dev-store patterns @@ -198,6 +199,28 @@ Test like a human user with mouse and keyboard. Use `browser_console_messages` t --- +## UI COMPONENT GENERATION + +When building UI components, check if MCP tools are available for component generation: + +- `mcp__ui_components__list_components` - See all available components in the library +- `mcp__ui_components__get_example` - Get implementation code for a component +- `mcp__ui_components__styling_guide` - Understand the styling approach + +**If these tools are available**, use them to generate components quickly instead of building from scratch. + +**If these tools are NOT available**, use the frontend-design skill for component creation: +- Invoke `/frontend-design` for complex UI components +- Follow the project's visual style from `app_spec.txt` +- Check `.autoforge/design-tokens.json` for style tokens if they exist + +**Visual Style Application:** +1. Read the `` section in `app_spec.txt` +2. If a design tokens file exists, apply those values to your CSS/Tailwind +3. Maintain consistency across all components + +--- + ## FEATURE TOOL USAGE RULES (CRITICAL - DO NOT VIOLATE) The feature tools exist to reduce token usage. **DO NOT make exploratory queries.** diff --git a/.claude/templates/initializer_prompt.template.md b/.claude/templates/initializer_prompt.template.md index bb914e2d..2c5822b8 100644 --- a/.claude/templates/initializer_prompt.template.md +++ b/.claude/templates/initializer_prompt.template.md @@ -272,6 +272,27 @@ The feature_list.json **MUST** include tests from ALL 20 categories. Minimum cou --- +## UI COMPONENT LIBRARY + +Check the `` section in `app_spec.txt` for the UI library configuration: + +- **library**: The component library to use (shadcn-ui, ark-ui, radix-ui, or none) +- **framework**: The frontend framework (react, vue, solid, svelte) +- **has_mcp**: Whether MCP tools are available for component generation + +If `has_mcp` is `true`, the coding agent will have access to MCP tools for rapid component generation. Factor this into feature descriptions where relevant. + +## VISUAL STYLE + +Check the `` section in `app_spec.txt` for styling configuration: + +- **style**: The visual aesthetic (default, neobrutalism, glassmorphism, retro, custom) +- **design_tokens_path**: Path to custom design tokens JSON if applicable + +For non-default styles, style-specific tests may be included (e.g., "Button has 4px border and offset shadow" for neobrutalism). + +--- + ## ABSOLUTE PROHIBITION: NO MOCK DATA The feature_list.json must include tests that **actively verify real data** and **detect mock data patterns**. diff --git a/CLAUDE.md b/CLAUDE.md index e0f9ea3e..b95cf2ab 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -287,6 +287,7 @@ Projects can be stored in any directory (registered in `~/.autoforge/registry.db - `.autoforge/features.db` - SQLite database with feature test cases - `.autoforge/.agent.lock` - Lock file to prevent multiple agent instances - `.autoforge/allowed_commands.yaml` - Project-specific bash command allowlist (optional) +- `.autoforge/design-tokens.json` - Visual style design tokens (generated for non-default styles) - `.autoforge/.gitignore` - Ignores runtime files - `CLAUDE.md` - Stays at project root (SDK convention) - `app_spec.txt` - Root copy for agent template compatibility @@ -398,6 +399,69 @@ blocked_commands: - `examples/org_config.yaml` - Org config example (all commented by default) - `examples/README.md` - Comprehensive guide with use cases, testing, and troubleshooting +### UI Component MCP Servers + +The agent can use MCP servers for rapid UI component generation when a compatible library is configured in `app_spec.txt`. + +**Supported Libraries:** +- `shadcn-ui` - Beautiful, accessible React components (MCP enabled) +- `ark-ui` - Headless primitives for React, Vue, Solid, Svelte (MCP enabled) +- `radix-ui` - Low-level headless primitives (no MCP, uses frontend-design skill) +- `none` - Custom components (no MCP, uses frontend-design skill) + +**Configuration in app_spec.txt:** +```xml + + shadcn-ui + react + true + +``` + +**MCP Tools Available:** +- `mcp__ui_components__list_components` - List available components +- `mcp__ui_components__get_example` - Get component implementation code +- `mcp__ui_components__styling_guide` - Get styling documentation + +**Environment Variables:** +- `DISABLE_UI_MCP=true` - Disable UI MCP server (for troubleshooting) +- `MCP_SHADCN_VERSION=1.0.0` - Pin shadcn MCP server version +- `MCP_ARK_VERSION=0.1.0` - Pin Ark UI MCP server version +- `GITHUB_PERSONAL_ACCESS_TOKEN` - GitHub token for better rate limits (optional) + +### Visual Styles and Design Tokens + +Projects can specify a visual style that generates design tokens for consistent styling. + +**Available Styles:** +- `default` - Clean, minimal design (no tokens generated) +- `neobrutalism` - Bold colors, hard shadows, 4px borders, no border-radius +- `glassmorphism` - Frosted glass effects, blur, transparency +- `retro` - Pixel-art inspired, vibrant neons, 8-bit aesthetic +- `custom` - User-defined tokens + +**Configuration in app_spec.txt:** +```xml + + + .autoforge/design-tokens.json + +``` + +**Design Tokens File (generated for non-default styles):** +```json +{ + "borders": {"width": "4px", "radius": "0"}, + "shadows": {"default": "4px 4px 0 0 currentColor"}, + "colors": {"primary": "#ff6b6b", "secondary": "#4ecdc4"} +} +``` + +**Files:** +- `app_spec_parser.py` - Shared parser for UI config from app_spec.txt +- `design_tokens.py` - Design token generation and style presets +- `test_ui_config.py` - Unit tests for UI configuration + ### Vertex AI Configuration (Optional) Run coding agents via Google Cloud Vertex AI: diff --git a/app_spec_parser.py b/app_spec_parser.py new file mode 100644 index 00000000..97ba7eaf --- /dev/null +++ b/app_spec_parser.py @@ -0,0 +1,215 @@ +""" +App Spec Parser +=============== + +Shared utilities for parsing app_spec.txt sections. +Used by client.py, prompts.py, and design_tokens.py to avoid code duplication. + +This module provides functions to: +- Extract XML sections from app_spec.txt +- Parse UI component library configuration +- Parse visual style configuration +- Combine configurations for the coding agent +""" + +import re +from pathlib import Path +from typing import TypedDict + +# ============================================================================= +# Constants +# ============================================================================= + +# Valid UI library options +VALID_UI_LIBRARIES = {"shadcn-ui", "ark-ui", "radix-ui", "none"} + +# Libraries that have MCP server support +MCP_SUPPORTED_LIBRARIES = {"shadcn-ui", "ark-ui"} + +# Valid visual style options +VALID_VISUAL_STYLES = {"default", "neobrutalism", "glassmorphism", "retro", "custom"} + +# Valid frameworks +VALID_FRAMEWORKS = {"react", "vue", "solid", "svelte"} + +# Regex pattern to match XML comments () +XML_COMMENT_PATTERN = re.compile(r"", re.DOTALL) + +# ============================================================================= +# Type Definitions +# ============================================================================= + + +class UIComponentsConfig(TypedDict, total=False): + """Configuration for UI component library.""" + library: str # shadcn-ui, ark-ui, radix-ui, none + framework: str # react, vue, solid, svelte + has_mcp: bool # Whether MCP server is available + + +class VisualStyleConfig(TypedDict, total=False): + """Configuration for visual style.""" + style: str # default, neobrutalism, glassmorphism, retro, custom + design_tokens_path: str # Path to custom design tokens JSON + + +class UIConfig(TypedDict, total=False): + """Combined UI configuration.""" + library: str + framework: str + has_mcp: bool + style: str + design_tokens_path: str + + +# ============================================================================= +# Parsing Functions +# ============================================================================= + + +def parse_section(content: str, section_name: str) -> str | None: + """ + Parse an XML section from app_spec.txt content. + + Args: + content: The full app_spec.txt content + section_name: The XML tag name to extract (e.g., "ui_components") + + Returns: + The content inside the section tags, or None if not found. + """ + pattern = rf"<{section_name}[^>]*>(.*?)" + match = re.search(pattern, content, re.DOTALL) + return match.group(1).strip() if match else None + + +def parse_xml_value(content: str, tag_name: str) -> str | None: + """ + Parse a single XML value from content. + + Extracts the text content from an XML tag, filtering out any XML comments. + Returns None if the tag is not found or contains only whitespace/comments. + + Args: + content: XML content to search + tag_name: The tag name to extract value from + + Returns: + The value inside the tags, or None if not found or empty. + + Example: + >>> parse_xml_value("shadcn-ui", "library") + 'shadcn-ui' + >>> parse_xml_value("", "library") + None + """ + pattern = rf"<{tag_name}[^>]*>(.*?)" + match = re.search(pattern, content, re.DOTALL) + if match: + value = match.group(1) + # Remove XML comments using regex pattern + value = XML_COMMENT_PATTERN.sub("", value).strip() + if value: + return value + return None + + +def parse_ui_components(content: str) -> UIComponentsConfig: + """ + Parse section from app_spec.txt content. + + Args: + content: The full app_spec.txt content + + Returns: + UIComponentsConfig with library, framework, and has_mcp fields. + Returns sensible defaults if section is not found. + """ + section = parse_section(content, "ui_components") + if not section: + return UIComponentsConfig( + library="none", + framework="react", + has_mcp=False, + ) + + library = parse_xml_value(section, "library") or "none" + framework = parse_xml_value(section, "framework") or "react" + has_mcp_str = parse_xml_value(section, "has_mcp") or "false" + has_mcp = has_mcp_str.lower() == "true" + + return UIComponentsConfig( + library=library, + framework=framework, + has_mcp=has_mcp, + ) + + +def parse_visual_style(content: str) -> VisualStyleConfig: + """ + Parse section from app_spec.txt content. + + Args: + content: The full app_spec.txt content + + Returns: + VisualStyleConfig with style and design_tokens_path fields. + Returns sensible defaults if section is not found. + """ + section = parse_section(content, "visual_style") + if not section: + return VisualStyleConfig( + style="default", + design_tokens_path="", + ) + + style = parse_xml_value(section, "style") or "default" + design_tokens_path = parse_xml_value(section, "design_tokens_path") or "" + + return VisualStyleConfig( + style=style, + design_tokens_path=design_tokens_path, + ) + + +def parse_ui_config(content: str) -> UIConfig: + """ + Parse both UI components and visual style configuration. + + Args: + content: The full app_spec.txt content + + Returns: + Combined UIConfig with all UI-related settings. + """ + ui_components = parse_ui_components(content) + visual_style = parse_visual_style(content) + + return UIConfig( + library=ui_components.get("library", "none"), + framework=ui_components.get("framework", "react"), + has_mcp=ui_components.get("has_mcp", False), + style=visual_style.get("style", "default"), + design_tokens_path=visual_style.get("design_tokens_path", ""), + ) + + +def get_ui_config_from_spec(project_dir: Path) -> UIConfig | None: + """ + Read and parse UI configuration from a project's app_spec.txt. + + Args: + project_dir: Path to the project directory + + Returns: + UIConfig if app_spec.txt exists and can be parsed, None otherwise. + """ + spec_path = project_dir / "prompts" / "app_spec.txt" + if not spec_path.exists(): + return None + + try: + content = spec_path.read_text(encoding="utf-8") + return parse_ui_config(content) + except (OSError, UnicodeDecodeError): + return None diff --git a/client.py b/client.py index a81a66db..df63ea78 100644 --- a/client.py +++ b/client.py @@ -7,6 +7,7 @@ import json import os +import platform import re import shutil import sys @@ -16,6 +17,7 @@ from claude_agent_sdk.types import HookContext, HookInput, HookMatcher, SyncHookJSONOutput from dotenv import load_dotenv +from app_spec_parser import get_ui_config_from_spec from security import SENSITIVE_DIRECTORIES, bash_security_hook # Load environment variables from .env file if present @@ -109,6 +111,39 @@ def get_playwright_browser() -> str: return value +def get_npx_command() -> str: + """ + Get the npx command that works on all platforms. + + On Windows, npx is often installed as npx.cmd and may not be found + via simple 'npx' lookup. This function handles platform-specific resolution. + + Returns: + Path to npx executable. + + Raises: + RuntimeError: If npx cannot be found. + """ + # Try standard npx first + npx_path = shutil.which("npx") + if npx_path: + return npx_path + + # On Windows, try npx.cmd + if platform.system() == "Windows": + npx_cmd = shutil.which("npx.cmd") + if npx_cmd: + return npx_cmd + + raise RuntimeError("npx not found. Install Node.js and ensure it's in PATH.") + + +# Version pinning for UI MCP servers to avoid breaking changes +# Can be overridden via environment variables +SHADCN_MCP_VERSION = os.getenv("MCP_SHADCN_VERSION", "latest") +ARK_MCP_VERSION = os.getenv("MCP_ARK_VERSION", "latest") + + def get_extra_read_paths() -> list[Path]: """ Get extra read-only paths from EXTRA_READ_PATHS environment variable. @@ -228,6 +263,17 @@ def get_extra_read_paths() -> list[Path]: set(CODING_AGENT_TOOLS) | set(TESTING_AGENT_TOOLS) | set(INITIALIZER_AGENT_TOOLS) ) +# UI Component MCP tools (shadcn-ui, ark-ui) +# These tools are only available when the project uses a UI library with MCP support +UI_MCP_TOOLS = [ + "mcp__ui_components__list_components", + "mcp__ui_components__list_examples", + "mcp__ui_components__get_example", + "mcp__ui_components__styling_guide", + "mcp__ui_components__get_component", + "mcp__ui_components__search_components", +] + # Playwright MCP tools for browser automation. # Full set of tools for comprehensive UI testing including drag-and-drop, # hover menus, file uploads, tab management, etc. @@ -309,6 +355,12 @@ def create_client( Note: Authentication is handled by start.bat/start.sh before this runs. The Claude SDK auto-detects credentials from the Claude CLI configuration """ + # Cache UI config once to avoid repeated file reads + # This is used for both allowed_tools and MCP server configuration + ui_mcp_disabled = os.getenv("DISABLE_UI_MCP", "").lower() == "true" + ui_config = None if ui_mcp_disabled else get_ui_config_from_spec(project_dir) + has_ui_mcp = ui_config is not None and ui_config.get("has_mcp", False) + # Select the feature MCP tools appropriate for this agent type feature_tools_map = { "coding": CODING_AGENT_TOOLS, @@ -333,6 +385,11 @@ def create_client( if not yolo_mode: allowed_tools.extend(PLAYWRIGHT_TOOLS) + # Add UI MCP tools if the project uses a library with MCP support + # UI MCP is available in both standard and YOLO mode + if has_ui_mcp: + allowed_tools.extend(UI_MCP_TOOLS) + # Build permissions list. # We permit ALL feature MCP tools at the security layer (so the MCP server # can respond if called), but the LLM only *sees* the agent-type-specific @@ -367,6 +424,10 @@ def create_client( # Allow Playwright MCP tools for browser automation (standard mode only) permissions_list.extend(PLAYWRIGHT_TOOLS) + # Add UI MCP tools to permissions if available + if has_ui_mcp: + permissions_list.extend(UI_MCP_TOOLS) + # Create comprehensive security settings # Note: Using relative paths ("./**") restricts access to project directory # since cwd is set to project_dir @@ -448,6 +509,53 @@ def create_client( "args": playwright_args, } + # UI Components MCP server (available in both standard and YOLO mode) + # Only added for libraries with MCP support (shadcn-ui, ark-ui) + if has_ui_mcp and ui_config: + library = ui_config.get("library", "") + framework = ui_config.get("framework", "react") + + try: + npx_cmd = get_npx_command() + + if library == "shadcn-ui": + # shadcn/ui MCP server for React components + # Uses GitHub API - benefits from GITHUB_PERSONAL_ACCESS_TOKEN for rate limits + ui_mcp_args = [ + "-y", + "--prefer-offline", + f"@jpisnice/shadcn-ui-mcp-server@{SHADCN_MCP_VERSION}", + "--framework", framework, + ] + ui_mcp_config: dict = { + "command": npx_cmd, + "args": ui_mcp_args, + } + # Only add env if there are environment variables to pass + github_token = os.getenv("GITHUB_PERSONAL_ACCESS_TOKEN") + if github_token: + ui_mcp_config["env"] = {"GITHUB_TOKEN": github_token} + + mcp_servers["ui_components"] = ui_mcp_config + print(f" - UI MCP: shadcn/ui ({framework})") + + elif library == "ark-ui": + # Ark UI MCP server for multi-framework headless components + ui_mcp_args = [ + "-y", + "--prefer-offline", + f"@ark-ui/mcp@{ARK_MCP_VERSION}", + ] + mcp_servers["ui_components"] = { + "command": npx_cmd, + "args": ui_mcp_args, + } + print(f" - UI MCP: Ark UI ({framework})") + + except RuntimeError as e: + # npx not found - graceful degradation + print(f" - Warning: UI MCP disabled - {e}") + # Build environment overrides for API endpoint configuration # Uses get_effective_sdk_env() which reads provider settings from the database, # ensuring UI-configured alternative providers (GLM, Ollama, Kimi, Custom) propagate diff --git a/design_tokens.py b/design_tokens.py new file mode 100644 index 00000000..6d4f7ab7 --- /dev/null +++ b/design_tokens.py @@ -0,0 +1,216 @@ +""" +Design Tokens Generator +======================= + +Generates design tokens based on visual style selection. +These tokens are used by the coding agent to apply consistent styling. +""" + +import json +from pathlib import Path +from typing import Any + +from app_spec_parser import VALID_VISUAL_STYLES, get_ui_config_from_spec + +# Style presets with design tokens +# Each preset defines CSS-friendly values for consistent styling +STYLE_PRESETS: dict[str, dict[str, Any]] = { + "neobrutalism": { + "description": "Bold colors, hard shadows, no border-radius", + "borders": { + "width": "4px", + "radius": "0", + "style": "solid", + "color": "currentColor", + }, + "shadows": { + "default": "4px 4px 0 0 currentColor", + "hover": "6px 6px 0 0 currentColor", + "active": "2px 2px 0 0 currentColor", + }, + "colors": { + "primary": "#ff6b6b", + "secondary": "#4ecdc4", + "accent": "#ffe66d", + "background": "#ffffff", + "surface": "#f8f9fa", + "text": "#000000", + "border": "#000000", + }, + "typography": { + "fontFamily": "'Inter', 'Helvetica Neue', sans-serif", + "fontWeight": { + "normal": "500", + "bold": "800", + }, + }, + "spacing": { + "base": "8px", + "scale": 1.5, + }, + "effects": { + "transition": "all 0.1s ease-in-out", + }, + }, + "glassmorphism": { + "description": "Frosted glass effects, blur, transparency", + "borders": { + "width": "1px", + "radius": "16px", + "style": "solid", + "color": "rgba(255, 255, 255, 0.2)", + }, + "shadows": { + "default": "0 8px 32px 0 rgba(31, 38, 135, 0.15)", + "hover": "0 12px 40px 0 rgba(31, 38, 135, 0.2)", + "active": "0 4px 16px 0 rgba(31, 38, 135, 0.1)", + }, + "colors": { + "primary": "#8b5cf6", + "secondary": "#06b6d4", + "accent": "#f472b6", + "background": "linear-gradient(135deg, #667eea 0%, #764ba2 100%)", + "surface": "rgba(255, 255, 255, 0.1)", + "text": "#ffffff", + "border": "rgba(255, 255, 255, 0.2)", + }, + "typography": { + "fontFamily": "'Inter', system-ui, sans-serif", + "fontWeight": { + "normal": "400", + "bold": "600", + }, + }, + "spacing": { + "base": "8px", + "scale": 1.5, + }, + "effects": { + "backdropBlur": "12px", + "backdropSaturate": "180%", + "transition": "all 0.3s ease", + }, + }, + "retro": { + "description": "Pixel-art inspired, vibrant neons, 8-bit aesthetic", + "borders": { + "width": "3px", + "radius": "0", + "style": "solid", + "color": "#00ffff", + }, + "shadows": { + "default": "0 0 10px #ff00ff, 0 0 20px #00ffff", + "hover": "0 0 15px #ff00ff, 0 0 30px #00ffff", + "active": "0 0 5px #ff00ff, 0 0 10px #00ffff", + }, + "colors": { + "primary": "#ff00ff", + "secondary": "#00ffff", + "accent": "#ffff00", + "background": "#0a0a0a", + "surface": "#1a1a2e", + "text": "#00ff00", + "border": "#00ffff", + }, + "typography": { + "fontFamily": "'Press Start 2P', 'Courier New', monospace", + "fontWeight": { + "normal": "400", + "bold": "400", + }, + "letterSpacing": "0.05em", + "textTransform": "uppercase", + }, + "spacing": { + "base": "8px", + "scale": 2, + }, + "effects": { + "textShadow": "0 0 5px currentColor", + "transition": "all 0.15s steps(3)", + }, + }, +} + + +def get_style_preset(style: str) -> dict[str, Any] | None: + """ + Get design tokens for a specific visual style. + + Args: + style: The style name (neobrutalism, glassmorphism, retro) + + Returns: + Design tokens dict or None if style is not found or is 'default'. + """ + if style == "default" or style not in STYLE_PRESETS: + return None + return STYLE_PRESETS[style] + + +def generate_design_tokens(project_dir: Path, style: str) -> Path | None: + """ + Generate design tokens JSON file for a project. + + Args: + project_dir: Path to the project directory + style: The visual style to use + + Returns: + Path to the generated tokens file, or None if style is default/custom + or if file write fails. + """ + # "default" uses library defaults, no tokens needed + # "custom" means user will define their own tokens manually + if style == "default" or style == "custom": + return None + + preset = get_style_preset(style) + if not preset: + return None + + # Create .autocoder directory if it doesn't exist + autocoder_dir = project_dir / ".autocoder" + autocoder_dir.mkdir(parents=True, exist_ok=True) + + # Write design tokens + tokens_path = autocoder_dir / "design-tokens.json" + try: + tokens_path.write_text(json.dumps(preset, indent=2), encoding="utf-8") + except OSError: + # File write failed (permissions, disk full, etc.) + return None + + return tokens_path + + +def generate_design_tokens_from_spec(project_dir: Path) -> Path | None: + """ + Generate design tokens based on project's app_spec.txt. + + Args: + project_dir: Path to the project directory + + Returns: + Path to the generated tokens file, or None if no tokens needed. + """ + ui_config = get_ui_config_from_spec(project_dir) + if not ui_config: + return None + + style = ui_config.get("style", "default") + return generate_design_tokens(project_dir, style) + + +def validate_visual_style(style: str) -> bool: + """ + Check if a visual style is valid. + + Args: + style: The style name to validate + + Returns: + True if valid, False otherwise. + """ + return style in VALID_VISUAL_STYLES diff --git a/server/services/spec_chat_session.py b/server/services/spec_chat_session.py index d3556173..d7006280 100644 --- a/server/services/spec_chat_session.py +++ b/server/services/spec_chat_session.py @@ -10,6 +10,7 @@ import logging import os import shutil +import sys import threading from datetime import datetime from pathlib import Path @@ -21,6 +22,10 @@ from ..schemas import ImageAttachment from .chat_constants import ROOT_DIR, make_multimodal_message +# Add root directory to path for imports +if str(ROOT_DIR) not in sys.path: + sys.path.insert(0, str(ROOT_DIR)) + # Load environment variables from .env file if present load_dotenv() @@ -411,6 +416,20 @@ async def _query_claude( if files_written["app_spec"] and files_written["initializer"]: logger.info("Both app_spec.txt and initializer_prompt.md verified - signaling completion") self.complete = True + + # Generate design tokens based on visual style (if not default) + try: + from design_tokens import generate_design_tokens_from_spec + tokens_path = generate_design_tokens_from_spec(self.project_dir) + if tokens_path: + logger.info(f"Generated design tokens at: {tokens_path}") + yield { + "type": "file_written", + "path": str(tokens_path.relative_to(self.project_dir)) + } + except Exception as e: + logger.warning(f"Failed to generate design tokens: {e}") + yield { "type": "spec_complete", "path": str(spec_path) diff --git a/test_ui_config.py b/test_ui_config.py new file mode 100644 index 00000000..fa772c5c --- /dev/null +++ b/test_ui_config.py @@ -0,0 +1,462 @@ +""" +Unit tests for UI configuration parsing and design tokens generation. +""" + +import json +import tempfile +from pathlib import Path + +import pytest + +from app_spec_parser import ( + MCP_SUPPORTED_LIBRARIES, + VALID_FRAMEWORKS, + VALID_UI_LIBRARIES, + VALID_VISUAL_STYLES, + get_ui_config_from_spec, + parse_section, + parse_ui_components, + parse_ui_config, + parse_visual_style, + parse_xml_value, +) +from design_tokens import ( + STYLE_PRESETS, + generate_design_tokens, + generate_design_tokens_from_spec, + get_style_preset, + validate_visual_style, +) + +# ============================================================================= +# Test: parse_section +# ============================================================================= + +class TestParseSection: + """Tests for parse_section function.""" + + def test_parse_simple_section(self): + content = "test content" + result = parse_section(content, "ui_components") + assert result == "test content" + + def test_parse_section_with_whitespace(self): + content = """ + + test content + + """ + result = parse_section(content, "ui_components") + assert result == "test content" + + def test_parse_section_with_attributes(self): + content = 'test content' + result = parse_section(content, "ui_components") + assert result == "test content" + + def test_parse_missing_section(self): + content = "test" + result = parse_section(content, "ui_components") + assert result is None + + def test_parse_empty_section(self): + content = "" + result = parse_section(content, "ui_components") + assert result == "" + + +# ============================================================================= +# Test: parse_xml_value +# ============================================================================= + +class TestParseXmlValue: + """Tests for parse_xml_value function.""" + + def test_parse_simple_value(self): + content = "shadcn-ui" + result = parse_xml_value(content, "library") + assert result == "shadcn-ui" + + def test_parse_value_with_whitespace(self): + content = " shadcn-ui " + result = parse_xml_value(content, "library") + assert result == "shadcn-ui" + + def test_parse_missing_value(self): + content = "value" + result = parse_xml_value(content, "library") + assert result is None + + def test_parse_empty_value(self): + content = "" + result = parse_xml_value(content, "library") + assert result is None # Empty string stripped to None + + def test_parse_comment_only(self): + content = "" + result = parse_xml_value(content, "library") + assert result is None + + +# ============================================================================= +# Test: parse_ui_components +# ============================================================================= + +class TestParseUIComponents: + """Tests for parse_ui_components function.""" + + def test_parse_complete_ui_components(self): + content = """ + + shadcn-ui + react + true + + """ + result = parse_ui_components(content) + assert result["library"] == "shadcn-ui" + assert result["framework"] == "react" + assert result["has_mcp"] is True + + def test_parse_ui_components_false_mcp(self): + content = """ + + radix-ui + react + false + + """ + result = parse_ui_components(content) + assert result["library"] == "radix-ui" + assert result["has_mcp"] is False + + def test_parse_ui_components_defaults(self): + content = "content" + result = parse_ui_components(content) + assert result["library"] == "none" + assert result["framework"] == "react" + assert result["has_mcp"] is False + + def test_parse_ark_ui(self): + content = """ + + ark-ui + vue + true + + """ + result = parse_ui_components(content) + assert result["library"] == "ark-ui" + assert result["framework"] == "vue" + assert result["has_mcp"] is True + + +# ============================================================================= +# Test: parse_visual_style +# ============================================================================= + +class TestParseVisualStyle: + """Tests for parse_visual_style function.""" + + def test_parse_complete_visual_style(self): + content = """ + + + .autocoder/design-tokens.json + + """ + result = parse_visual_style(content) + assert result["style"] == "neobrutalism" + assert result["design_tokens_path"] == ".autocoder/design-tokens.json" + + def test_parse_visual_style_default(self): + content = """ + + + + """ + result = parse_visual_style(content) + assert result["style"] == "default" + assert result["design_tokens_path"] == "" + + def test_parse_visual_style_missing(self): + content = "content" + result = parse_visual_style(content) + assert result["style"] == "default" + assert result["design_tokens_path"] == "" + + +# ============================================================================= +# Test: parse_ui_config +# ============================================================================= + +class TestParseUIConfig: + """Tests for parse_ui_config function.""" + + def test_parse_complete_config(self): + content = """ + + shadcn-ui + react + true + + + + .autocoder/design-tokens.json + + """ + result = parse_ui_config(content) + assert result["library"] == "shadcn-ui" + assert result["framework"] == "react" + assert result["has_mcp"] is True + assert result["style"] == "neobrutalism" + assert result["design_tokens_path"] == ".autocoder/design-tokens.json" + + def test_parse_empty_config(self): + content = "" + result = parse_ui_config(content) + assert result["library"] == "none" + assert result["framework"] == "react" + assert result["has_mcp"] is False + assert result["style"] == "default" + + +# ============================================================================= +# Test: get_ui_config_from_spec +# ============================================================================= + +class TestGetUIConfigFromSpec: + """Tests for get_ui_config_from_spec function.""" + + def test_get_config_from_existing_spec(self): + with tempfile.TemporaryDirectory() as tmpdir: + project_dir = Path(tmpdir) + prompts_dir = project_dir / "prompts" + prompts_dir.mkdir() + + spec_content = """ + + + ark-ui + solid + true + + + + + + """ + (prompts_dir / "app_spec.txt").write_text(spec_content) + + result = get_ui_config_from_spec(project_dir) + assert result is not None + assert result["library"] == "ark-ui" + assert result["framework"] == "solid" + assert result["has_mcp"] is True + assert result["style"] == "glassmorphism" + + def test_get_config_missing_spec(self): + with tempfile.TemporaryDirectory() as tmpdir: + project_dir = Path(tmpdir) + result = get_ui_config_from_spec(project_dir) + assert result is None + + +# ============================================================================= +# Test: STYLE_PRESETS +# ============================================================================= + +class TestStylePresets: + """Tests for style presets in design_tokens.py.""" + + def test_neobrutalism_preset_exists(self): + assert "neobrutalism" in STYLE_PRESETS + preset = STYLE_PRESETS["neobrutalism"] + assert preset["borders"]["width"] == "4px" + assert preset["borders"]["radius"] == "0" + assert "4px 4px 0 0" in preset["shadows"]["default"] + + def test_glassmorphism_preset_exists(self): + assert "glassmorphism" in STYLE_PRESETS + preset = STYLE_PRESETS["glassmorphism"] + assert preset["borders"]["radius"] == "16px" + assert preset["effects"]["backdropBlur"] == "12px" + + def test_retro_preset_exists(self): + assert "retro" in STYLE_PRESETS + preset = STYLE_PRESETS["retro"] + assert preset["colors"]["primary"] == "#ff00ff" + assert preset["colors"]["secondary"] == "#00ffff" + assert "Press Start 2P" in preset["typography"]["fontFamily"] + + +# ============================================================================= +# Test: get_style_preset +# ============================================================================= + +class TestGetStylePreset: + """Tests for get_style_preset function.""" + + def test_get_neobrutalism_preset(self): + preset = get_style_preset("neobrutalism") + assert preset is not None + assert "borders" in preset + assert "shadows" in preset + + def test_get_default_preset(self): + preset = get_style_preset("default") + assert preset is None + + def test_get_unknown_preset(self): + preset = get_style_preset("unknown_style") + assert preset is None + + +# ============================================================================= +# Test: generate_design_tokens +# ============================================================================= + +class TestGenerateDesignTokens: + """Tests for generate_design_tokens function.""" + + def test_generate_neobrutalism_tokens(self): + with tempfile.TemporaryDirectory() as tmpdir: + project_dir = Path(tmpdir) + result = generate_design_tokens(project_dir, "neobrutalism") + + assert result is not None + assert result.exists() + assert result.name == "design-tokens.json" + + tokens = json.loads(result.read_text()) + assert tokens["borders"]["width"] == "4px" + + def test_generate_default_tokens(self): + with tempfile.TemporaryDirectory() as tmpdir: + project_dir = Path(tmpdir) + result = generate_design_tokens(project_dir, "default") + assert result is None + + def test_generate_custom_tokens(self): + with tempfile.TemporaryDirectory() as tmpdir: + project_dir = Path(tmpdir) + result = generate_design_tokens(project_dir, "custom") + assert result is None + + +# ============================================================================= +# Test: generate_design_tokens_from_spec +# ============================================================================= + +class TestGenerateDesignTokensFromSpec: + """Tests for generate_design_tokens_from_spec function.""" + + def test_generate_tokens_from_neobrutalism_spec(self): + """Integration test: generate tokens from a spec file with neobrutalism style.""" + with tempfile.TemporaryDirectory() as tmpdir: + project_dir = Path(tmpdir) + prompts_dir = project_dir / "prompts" + prompts_dir.mkdir() + + spec_content = """ + + + + .autocoder/design-tokens.json + + + """ + (prompts_dir / "app_spec.txt").write_text(spec_content) + + result = generate_design_tokens_from_spec(project_dir) + + assert result is not None + assert result.exists() + assert result.name == "design-tokens.json" + + tokens = json.loads(result.read_text()) + assert tokens["borders"]["width"] == "4px" + assert tokens["borders"]["radius"] == "0" + + def test_generate_tokens_from_default_spec(self): + """No tokens generated for default style.""" + with tempfile.TemporaryDirectory() as tmpdir: + project_dir = Path(tmpdir) + prompts_dir = project_dir / "prompts" + prompts_dir.mkdir() + + spec_content = """ + + + + + + """ + (prompts_dir / "app_spec.txt").write_text(spec_content) + + result = generate_design_tokens_from_spec(project_dir) + assert result is None + + def test_generate_tokens_missing_spec(self): + """Returns None when spec file doesn't exist.""" + with tempfile.TemporaryDirectory() as tmpdir: + project_dir = Path(tmpdir) + result = generate_design_tokens_from_spec(project_dir) + assert result is None + + +# ============================================================================= +# Test: validate_visual_style +# ============================================================================= + +class TestValidateVisualStyle: + """Tests for validate_visual_style function.""" + + def test_valid_styles(self): + assert validate_visual_style("default") is True + assert validate_visual_style("neobrutalism") is True + assert validate_visual_style("glassmorphism") is True + assert validate_visual_style("retro") is True + assert validate_visual_style("custom") is True + + def test_invalid_styles(self): + assert validate_visual_style("unknown") is False + assert validate_visual_style("") is False + assert validate_visual_style("NEOBRUTALISM") is False # Case sensitive + + +# ============================================================================= +# Test: Constants +# ============================================================================= + +class TestConstants: + """Tests for module constants.""" + + def test_valid_ui_libraries(self): + assert "shadcn-ui" in VALID_UI_LIBRARIES + assert "ark-ui" in VALID_UI_LIBRARIES + assert "radix-ui" in VALID_UI_LIBRARIES + assert "none" in VALID_UI_LIBRARIES + + def test_mcp_supported_libraries(self): + assert "shadcn-ui" in MCP_SUPPORTED_LIBRARIES + assert "ark-ui" in MCP_SUPPORTED_LIBRARIES + assert "radix-ui" not in MCP_SUPPORTED_LIBRARIES + assert "none" not in MCP_SUPPORTED_LIBRARIES + + def test_valid_visual_styles(self): + assert "default" in VALID_VISUAL_STYLES + assert "neobrutalism" in VALID_VISUAL_STYLES + assert "glassmorphism" in VALID_VISUAL_STYLES + assert "retro" in VALID_VISUAL_STYLES + assert "custom" in VALID_VISUAL_STYLES + + def test_valid_frameworks(self): + assert "react" in VALID_FRAMEWORKS + assert "vue" in VALID_FRAMEWORKS + assert "solid" in VALID_FRAMEWORKS + assert "svelte" in VALID_FRAMEWORKS + + +if __name__ == "__main__": + pytest.main([__file__, "-v"]) diff --git a/ui/public/previews/README.md b/ui/public/previews/README.md new file mode 100644 index 00000000..93eac8f7 --- /dev/null +++ b/ui/public/previews/README.md @@ -0,0 +1,32 @@ +# UI Preview Images + +This directory contains preview images for UI library and visual style selectors. + +## Required Images + +### UI Libraries +- `shadcn-button.png` - shadcn/ui button component preview (400x300) +- `shadcn-card.png` - shadcn/ui card component preview (400x300) +- `shadcn-dialog.png` - shadcn/ui dialog component preview (400x300) +- `ark-accordion.png` - Ark UI accordion component preview (400x300) +- `ark-combobox.png` - Ark UI combobox component preview (400x300) +- `ark-tooltip.png` - Ark UI tooltip component preview (400x300) +- `radix-primitives.png` - Radix UI primitives preview (400x300) + +### Visual Styles +- `style-default.png` - Modern/Clean style preview (400x300) +- `style-neobrutalism.png` - Neobrutalism style preview (400x300) +- `style-glassmorphism.png` - Glassmorphism style preview (400x300) +- `style-retro.png` - Retro/Arcade style preview (400x300) + +## Image Guidelines + +- Format: PNG with transparency where appropriate +- Size: 400x300 pixels +- Show component or style clearly +- Use consistent backgrounds +- Optimize for web (compress without quality loss) + +## Fallback Behavior + +If an image is not found, the selector components will display a placeholder or icon instead. diff --git a/ui/src/components/UILibrarySelector.tsx b/ui/src/components/UILibrarySelector.tsx new file mode 100644 index 00000000..10c17ba4 --- /dev/null +++ b/ui/src/components/UILibrarySelector.tsx @@ -0,0 +1,186 @@ +/** + * UI Library Selector Component + * + * Allows users to select a UI component library during project creation. + * Shows preview images and MCP availability badges. + */ + +import { useState } from 'react' +import { Check, Zap, Code2, Sparkles, Box } from 'lucide-react' +import { cn } from '@/lib/utils' +import { Card, CardContent, CardDescription, CardHeader, CardTitle } from '@/components/ui/card' +import { Badge } from '@/components/ui/badge' + +export interface UILibrary { + id: string + name: string + description: string + frameworks: string[] + hasMcp: boolean + isRecommended?: boolean + previewImage?: string +} + +const UI_LIBRARIES: UILibrary[] = [ + { + id: 'shadcn-ui', + name: 'shadcn/ui', + description: 'Beautiful, accessible React components with Radix primitives', + frameworks: ['react'], + hasMcp: true, + isRecommended: true, + }, + { + id: 'ark-ui', + name: 'Ark UI', + description: 'Headless primitives for any framework', + frameworks: ['react', 'vue', 'solid', 'svelte'], + hasMcp: true, + }, + { + id: 'radix-ui', + name: 'Radix UI', + description: 'Low-level headless primitives', + frameworks: ['react'], + hasMcp: false, + }, + { + id: 'none', + name: 'Custom/None', + description: 'Build components from scratch', + frameworks: ['react', 'vue', 'solid', 'svelte', 'vanilla'], + hasMcp: false, + }, +] + +interface UILibrarySelectorProps { + value: string + framework: string + onChange: (libraryId: string) => void + className?: string +} + +export function UILibrarySelector({ + value, + framework, + onChange, + className, +}: UILibrarySelectorProps) { + const [hoveredId, setHoveredId] = useState(null) + + const getLibraryIcon = (id: string) => { + switch (id) { + case 'shadcn-ui': + return + case 'ark-ui': + return + case 'radix-ui': + return + case 'none': + return + default: + return + } + } + + const isCompatible = (lib: UILibrary) => { + // 'none' is always compatible as it's a catch-all for any framework + return lib.frameworks.includes(framework) + } + + return ( +
+ {UI_LIBRARIES.map((lib) => { + const selected = value === lib.id + const compatible = isCompatible(lib) + // hoveredId is tracked for potential future hover preview effects + const _hovered = hoveredId === lib.id + void _hovered // Suppress unused variable warning + + return ( + compatible && onChange(lib.id)} + onMouseEnter={() => setHoveredId(lib.id)} + onMouseLeave={() => setHoveredId(null)} + role="option" + aria-selected={selected} + aria-disabled={!compatible} + aria-label={`Select ${lib.name} component library`} + tabIndex={compatible ? 0 : -1} + onKeyDown={(e) => { + if (compatible && (e.key === 'Enter' || e.key === ' ')) { + e.preventDefault() + onChange(lib.id) + } + }} + > + +
+
+ {getLibraryIcon(lib.id)} + {lib.name} +
+ {selected && ( +
+ +
+ )} +
+
+ {lib.hasMcp && ( + + + MCP Enabled + + )} + {lib.isRecommended && framework === 'react' && ( + + Recommended + + )} + {!compatible && ( + + Not compatible with {framework} + + )} +
+
+ + + {lib.description} + + {lib.frameworks.length > 0 && lib.id !== 'none' && ( +
+ {lib.frameworks.map((fw) => ( + + {fw} + + ))} +
+ )} +
+
+ ) + })} +
+ ) +} + +export { UI_LIBRARIES } diff --git a/ui/src/components/VisualStyleSelector.tsx b/ui/src/components/VisualStyleSelector.tsx new file mode 100644 index 00000000..7da2975d --- /dev/null +++ b/ui/src/components/VisualStyleSelector.tsx @@ -0,0 +1,236 @@ +/** + * Visual Style Selector Component + * + * Allows users to select a visual style during project creation. + * Shows color swatches and style previews. + */ + +import { useState } from 'react' +import { Check, Paintbrush, Sparkles, Layers, Gamepad2, Settings2 } from 'lucide-react' +import { cn } from '@/lib/utils' +import { Card, CardContent, CardDescription, CardHeader, CardTitle } from '@/components/ui/card' +import { Badge } from '@/components/ui/badge' + +export interface VisualStyle { + id: string + name: string + description: string + colors: string[] + generatesTokens: boolean + previewImage?: string +} + +const VISUAL_STYLES: VisualStyle[] = [ + { + id: 'default', + name: 'Modern/Clean', + description: 'Minimal, professional design following library defaults', + colors: ['#3b82f6', '#64748b', '#ffffff'], + generatesTokens: false, + }, + { + id: 'neobrutalism', + name: 'Neobrutalism', + description: 'Bold colors, hard shadows, no border-radius', + colors: ['#ff6b6b', '#4ecdc4', '#000000'], + generatesTokens: true, + }, + { + id: 'glassmorphism', + name: 'Glassmorphism', + description: 'Frosted glass effects, blur, transparency', + colors: ['#8b5cf6', '#06b6d4', '#f472b6'], + generatesTokens: true, + }, + { + id: 'retro', + name: 'Retro/Arcade', + description: 'Pixel-art inspired, vibrant neons, 8-bit aesthetic', + colors: ['#ff00ff', '#00ffff', '#ffff00'], + generatesTokens: true, + }, + { + id: 'custom', + name: 'Custom', + description: 'Define your own design tokens', + colors: [], + generatesTokens: true, + }, +] + +interface VisualStyleSelectorProps { + value: string + onChange: (styleId: string) => void + className?: string +} + +export function VisualStyleSelector({ + value, + onChange, + className, +}: VisualStyleSelectorProps) { + const [hoveredId, setHoveredId] = useState(null) + + const getStyleIcon = (id: string) => { + switch (id) { + case 'default': + return + case 'neobrutalism': + return + case 'glassmorphism': + return + case 'retro': + return + case 'custom': + return + default: + return + } + } + + const renderColorSwatches = (colors: string[]) => { + if (colors.length === 0) return null + + return ( +
+ {colors.map((color, index) => ( +
+ ))} +
+ ) + } + + const renderStylePreview = (style: VisualStyle) => { + switch (style.id) { + case 'neobrutalism': + return ( +
+
+ Button Preview +
+
+ ) + case 'glassmorphism': + return ( +
+
+ Button Preview +
+
+ ) + case 'retro': + return ( +
+
+ Button Preview +
+
+ ) + default: + return null + } + } + + return ( +
+ {VISUAL_STYLES.map((style) => { + const selected = value === style.id + // hoveredId is tracked for potential future hover preview effects + const _hovered = hoveredId === style.id + void _hovered // Suppress unused variable warning + + return ( + onChange(style.id)} + onMouseEnter={() => setHoveredId(style.id)} + onMouseLeave={() => setHoveredId(null)} + role="option" + aria-selected={selected} + aria-label={`Select ${style.name} visual style`} + tabIndex={0} + onKeyDown={(e) => { + if (e.key === 'Enter' || e.key === ' ') { + e.preventDefault() + onChange(style.id) + } + }} + > + +
+
+ {getStyleIcon(style.id)} + {style.name} +
+ {selected && ( +
+ +
+ )} +
+ {style.generatesTokens && style.id !== 'custom' && ( + + Generates design tokens + + )} + {style.id === 'default' && ( + + Recommended + + )} +
+ + + {style.description} + + {renderColorSwatches(style.colors)} + {renderStylePreview(style)} + +
+ ) + })} +
+ ) +} + +export { VISUAL_STYLES }