/** * Configuration Screen Component * * Terminal UI Best Practices Applied: * * 1. FOCUS INDICATOR - Use `▶` cursor, not just color (accessibility) * 2. KEYBOARD NAVIGATION: * - ↑↓ to move between fields * - ←→ to change values in radio/select fields * - Enter to confirm/submit * 3. VISUAL HIERARCHY: * - Focused: `▶` prefix + bright text * - Selected: `●` marker (in radio groups) * - Editable: `[value]` bracket notation for input fields * 4. SINGLE COLUMN LAYOUT - Predictable top-to-bottom navigation * 5. KEYBINDINGS VISIBLE - Always show available actions at footer * 6. FOOTER CONTEXTUAL HELP - Tips at bottom, below keybindings */ import { useKeyboard } from '@opentui/react' import { useEffect, useMemo, useState } from 'react' import type { AssetGeneratorConfig, AssetType, BackgroundConfig, ForegroundConfig, Platform, } from '../types' import { initGoogleFonts, normalizeFontFamily, searchFonts, } from '../utils/google_fonts' import { resolvePath } from '../utils/path_utils' import { LivePreview } from './live_preview' // ─── Utility Functions ───────────────────────────────────────────────────── /** * Search fonts by query (case-insensitive, partial match). * * @param query - Search string to match against font names. * @returns Array of matching font family names. */ function autocompleteFonts(query: string): string[] { return searchFonts(query).map(f => f.family) } /** * Truncate text to fit terminal width with ellipsis. * * @param text - Text to truncate. * @param maxLen - Maximum allowed length. * @returns Truncated text with '...' if needed. */ function truncate(text: string, maxLen: number): string { if (text.length <= maxLen) return text return `${text.slice(0, maxLen - 3)}...` } // ─── Layout Constants ────────────────────────────────────────────────────── /** * Responsive breakpoints based on terminal column width. * Determines whether to show side-by-side preview or stacked layout. */ const BREAKPOINTS = { wide: 100, // Side-by-side layout with live preview. medium: 60, // Stacked layout with live preview. // Below medium: form only, no preview (narrow terminals). } // ─── Theme Colors ────────────────────────────────────────────────────────── // Terminal-agnostic colors that adapt to both light and dark terminals. const colors = { text: 'white', textMuted: 'gray', textDim: 'gray', accent: 'cyan', tip: 'green', warning: 'yellow', } as const // ─── Field Definitions ───────────────────────────────────────────────────── type FieldType = 'input' | 'radio' | 'scale' /** Definition for a single form field. */ interface FieldDef { id: string label: string type: FieldType options?: string[] tip: string min?: number max?: number step?: number } /** * Field definitions for the configuration form. * Each field has a type, label, and contextual tip. */ const FIELDS: FieldDef[] = [ // Application metadata. { id: 'appName', label: 'App Name', type: 'input', tip: 'Appears in app stores and device home screens', }, // Background configuration. { id: 'bgType', label: 'Background Type', type: 'radio', options: ['Color', 'Gradient', 'Image'], tip: 'Solid colors work best for icons', }, { id: 'bgColor', label: 'Background Color', type: 'input', tip: 'Use #F7F5F0 for newspaper look', }, // Foreground configuration. { id: 'fgType', label: 'Foreground Type', type: 'radio', options: ['Text', 'SVG', 'Image'], tip: 'Text icons are crisp at all sizes', }, { id: 'fgText', label: 'Text', type: 'input', tip: 'Single characters work best: " X ★', }, { id: 'fgFont', label: 'Font Family', type: 'input', tip: 'Any Google Font: Playfair Display, Inter...', }, { id: 'fgColor', label: 'Text Color', type: 'input', tip: 'Ensure contrast with background', }, // Scale configuration. { id: 'iconScale', label: 'Icon Scale', type: 'scale', min: 0.1, max: 1.5, step: 0.05, tip: '0.6-0.7 standard • 0.8-0.9 bold • 1.0+ edge-to-edge', }, { id: 'splashScale', label: 'Splash Scale', type: 'scale', min: 0.05, max: 1.0, step: 0.05, tip: '0.2-0.3 standard • 0.5-1.0 large', }, { id: 'outputPath', label: 'Output Path', type: 'input', tip: 'Leave empty for timestamped folder, or enter custom path', }, ] // ─── Scale Warnings (Platform Guidelines) ────────────────────────────────── /** * Get warning message for icon scale based on platform guidelines. * - Android: Safe zone is 66dp/108dp ≈ 61% (Material Design guidelines) * - iOS: No strict limit, but content should be visible at small sizes */ function getIconScaleWarning(scale: number): string | null { if (scale > 0.66) { return `⚠ Android: >66% may clip (safe zone is 66dp of 108dp canvas)` } if (scale < 0.4) { return `ℹ Icon may be hard to see at small sizes (20px)` } return null } /** * Get warning message for splash scale based on platform guidelines. * - iOS HIG: Splash should be simple, centered branding * - Android: Splash icon recommended at 200dp max */ function getSplashScaleWarning(scale: number): string | null { if (scale > 0.5) { return `ℹ Large splash may feel overwhelming on smaller devices` } if (scale < 0.1) { return `ℹ Very small splash may be hard to see` } return null } // ─── Form State Interface ────────────────────────────────────────────────── /** * Form state that persists across navigation (lifted to App component). * Contains all user-configurable values for asset generation. */ export interface ConfigFormState { appName: string bgType: number bgColor: string fgType: number fgText: string fgFont: string fgColor: string iconScale: number splashScale: number outputPath: string } // ─── Color Utility Functions ─────────────────────────────────────────────── /** * Validates if a string is a valid hex color. * * @param color - Color string to validate. * @returns True if valid hex format (#RGB, #RRGGBB, #RGBA, #RRGGBBAA). */ function isValidHexColor(color: string): boolean { return /^#([0-9A-Fa-f]{3}|[0-9A-Fa-f]{4}|[0-9A-Fa-f]{6}|[0-9A-Fa-f]{8})$/.test( color, ) } /** * Expands shorthand hex (#RGB) to full form (#RRGGBB). * * @param hex - Hex color (3 or 6 digit). * @returns 6-digit hex color. */ function expandHex(hex: string): string { if (hex.length === 4) { return `#${hex[1]}${hex[1]}${hex[2]}${hex[2]}${hex[3]}${hex[3]}` } return hex } /** * Calculates relative luminance of a hex color. * * Uses ITU-R BT.601 coefficients for perceived brightness. * This matches how humans perceive brightness (green > red > blue). * * @param hex - Hex color code. * @returns Luminance value from 0 (black) to 1 (white). */ function getLuminance(hex: string): number { const expanded = expandHex(hex) const r = parseInt(expanded.slice(1, 3), 16) / 255 const g = parseInt(expanded.slice(3, 5), 16) / 255 const b = parseInt(expanded.slice(5, 7), 16) / 255 return 0.299 * r + 0.587 * g + 0.114 * b } /** * Returns a contrasting border color based on luminance. * * Used for color preview boxes to ensure visibility against any background. * Light colors get dark border, dark colors get light border. * * @param hex - Hex color code. * @returns Contrasting border color. */ function getContrastBorder(hex: string): string { const luminance = getLuminance(hex) return luminance > 0.6 ? '#666666' : '#CCCCCC' } /** * Generate datetime-based output directory path. * * @returns Path like './assets/generated_2024-01-15_14-30-52'. */ function getOutputDir(): string { const now = new Date() const date = now.toISOString().slice(0, 10) // YYYY-MM-DD const time = now.toISOString().slice(11, 19).replace(/:/g, '-') // HH-MM-SS return `./assets/generated_${date}_${time}` } // ─── Component Props ─────────────────────────────────────────────────────── interface ConfigScreenProps { formState: ConfigFormState onFormStateChange: (state: ConfigFormState) => void onComplete: (config: AssetGeneratorConfig) => void /** Optional callback to navigate back (only available when history exists). */ onBack?: (() => void) | undefined /** Optional callback to open history screen (only available when history exists). */ onHistory?: (() => void) | undefined } /** Default values for each field (used for ESC reset). */ const DEFAULTS: ConfigFormState = { appName: 'MyApp', bgType: 0, bgColor: '#FFFFFF', fgType: 0, fgText: 'A', fgFont: 'Inter', fgColor: '#000000', iconScale: 0.7, splashScale: 0.25, outputPath: '', } // ─── Main Component ──────────────────────────────────────────────────────── /** * Configuration screen for asset generation settings. * * Provides a keyboard-navigable form with live preview. Supports: * - Input fields for text values. * - Radio groups for type selection. * - Scale sliders for size adjustments. * - Font autocomplete from Google Fonts. */ export function ConfigScreen({ formState, onFormStateChange, onComplete, onBack, onHistory, }: ConfigScreenProps) { // ─── State ───────────────────────────────────────────────────────────────── const [focusIndex, setFocusIndex] = useState(0) // Destructure form values from lifted state (session-persistent). const { appName, bgType, bgColor, fgType, fgText, fgFont, fgColor, iconScale, splashScale, outputPath, } = formState // Font autocomplete suggestions and loading state. const [fontSuggestions, setFontSuggestions] = useState([]) const [_fontsLoaded, setFontsLoaded] = useState(false) // Terminal size for responsive layout decisions. const [terminalWidth, setTerminalWidth] = useState( process.stdout.columns || 80, ) // ─── Form State Helpers ──────────────────────────────────────────────────── /** Update a single field in the form state. */ const updateField = ( key: K, value: ConfigFormState[K], ) => { onFormStateChange({ ...formState, [key]: value }) } // ─── Effects ─────────────────────────────────────────────────────────────── // Initialize Google Fonts cache on mount (fetches 1,500+ fonts). useEffect(() => { initGoogleFonts().then(() => setFontsLoaded(true)) }, []) // Listen for terminal resize events. useEffect(() => { const handleResize = () => { setTerminalWidth(process.stdout.columns || 80) } process.stdout.on('resize', handleResize) return () => { process.stdout.off('resize', handleResize) } }, []) // Update font suggestions when font input changes (instant local search). useEffect(() => { if (fgFont.length < 1) { setFontSuggestions([]) return } const results = autocompleteFonts(fgFont) setFontSuggestions(results) }, [fgFont]) // ─── Computed Values ─────────────────────────────────────────────────────── const scaleFields = FIELDS.filter(f => f.type === 'scale') // Determine layout based on terminal width. const showPreview = terminalWidth >= BREAKPOINTS.medium const sideBySide = terminalWidth >= BREAKPOINTS.wide /** * Compute visible fields based on current type selections. * * Dynamically shows/hides fields based on background and foreground types. * For example, bgColor is only shown when bgType is 'Color'. */ const getVisibleFields = (): FieldDef[] => { const visible: FieldDef[] = [] // Always show appName and bgType const appNameField = FIELDS.find(f => f.id === 'appName') const bgTypeField = FIELDS.find(f => f.id === 'bgType') if (appNameField) visible.push(appNameField) if (bgTypeField) visible.push(bgTypeField) // Show bgColor only for Color type if (bgType === 0) { const bgColorField = FIELDS.find(f => f.id === 'bgColor') if (bgColorField) visible.push(bgColorField) } // Always show fgType const fgTypeField = FIELDS.find(f => f.id === 'fgType') if (fgTypeField) visible.push(fgTypeField) // Show text-related fields only for Text type if (fgType === 0) { const fgTextField = FIELDS.find(f => f.id === 'fgText') const fgFontField = FIELDS.find(f => f.id === 'fgFont') const fgColorField = FIELDS.find(f => f.id === 'fgColor') if (fgTextField) visible.push(fgTextField) if (fgFontField) visible.push(fgFontField) if (fgColorField) visible.push(fgColorField) } // Always show scale fields at the end visible.push(...scaleFields) // Always show output path field at the end const outputPathField = FIELDS.find(f => f.id === 'outputPath') if (outputPathField) visible.push(outputPathField) return visible } const visibleFields = getVisibleFields() const currentField = visibleFields[focusIndex] // Build current background config for live preview. const currentBackground = useMemo((): BackgroundConfig => { if (bgType === 0) { return { type: 'color', color: { type: 'solid', color: bgColor } } } else if (bgType === 1) { return { type: 'gradient', gradient: { type: 'linear', colors: ['#B3D9E8', '#004C6E'], angle: 180, }, } } else { return { type: 'image', imagePath: './background.png' } } }, [bgType, bgColor]) // Build current foreground config for live preview. const currentForeground = useMemo((): ForegroundConfig => { if (fgType === 0) { // Normalize font family to correct casing for API calls. const normalizedFont = normalizeFontFamily(fgFont) return { type: 'text', text: fgText, fontFamily: normalizedFont, color: fgColor, fontSource: 'google', } } else if (fgType === 1) { return { type: 'svg', svgPath: './icon.svg', color: fgColor } } else { return { type: 'image', imagePath: './icon.png' } } }, [fgType, fgText, fgFont, fgColor]) // ─── Navigation Handlers ─────────────────────────────────────────────────── /** Move focus up or down in the field list. */ const navigate = (dir: 'up' | 'down') => { if (dir === 'up' && focusIndex > 0) { setFocusIndex(focusIndex - 1) } else if (dir === 'down' && focusIndex < visibleFields.length - 1) { setFocusIndex(focusIndex + 1) } } /** Change radio button selection left or right. */ const changeRadio = (dir: 'left' | 'right') => { if (!currentField || currentField.type !== 'radio' || !currentField.options) return const max = currentField.options.length - 1 if (currentField.id === 'bgType') { if (dir === 'left' && bgType > 0) updateField('bgType', bgType - 1) if (dir === 'right' && bgType < max) updateField('bgType', bgType + 1) } else if (currentField.id === 'fgType') { if (dir === 'left' && fgType > 0) updateField('fgType', fgType - 1) if (dir === 'right' && fgType < max) updateField('fgType', fgType + 1) } } /** Adjust scale slider value left or right. */ const changeScale = (dir: 'left' | 'right') => { if (!currentField || currentField.type !== 'scale') return const { min = 0, max = 1, step = 0.05 } = currentField if (currentField.id === 'iconScale') { const newValue = dir === 'left' ? Math.max(min, iconScale - step) : Math.min(max, iconScale + step) updateField('iconScale', Math.round(newValue * 100) / 100) } else if (currentField.id === 'splashScale') { const newValue = dir === 'left' ? Math.max(min, splashScale - step) : Math.min(max, splashScale + step) updateField('splashScale', Math.round(newValue * 100) / 100) } } // ─── Field Value Accessors ───────────────────────────────────────────────── /** Get the current value for a text input field. */ const getValue = (fieldId: string): string => { switch (fieldId) { case 'appName': return appName case 'bgColor': return bgColor case 'fgText': return fgText case 'fgFont': return fgFont case 'fgColor': return fgColor case 'outputPath': return outputPath default: return '' } } /** Set the value for a text input field. */ const setValue = (fieldId: string, value: string) => { switch (fieldId) { case 'appName': updateField('appName', value) break case 'bgColor': updateField('bgColor', value) break case 'fgText': updateField('fgText', value) break case 'fgFont': updateField('fgFont', value) break case 'fgColor': updateField('fgColor', value) break case 'outputPath': updateField('outputPath', value) break } } /** Reset current field to its default value (ESC key). */ const resetCurrentField = () => { if (!currentField) return const fieldId = currentField.id as keyof ConfigFormState if (fieldId in DEFAULTS) { updateField(fieldId, DEFAULTS[fieldId]) } } // ─── Keyboard Handler ────────────────────────────────────────────────────── useKeyboard(key => { if (key.name === 'up') navigate('up') else if (key.name === 'down' || key.name === 'tab') navigate('down') else if (key.name === 'left') { if (currentField?.type === 'radio') changeRadio('left') else if (currentField?.type === 'scale') changeScale('left') } else if (key.name === 'right') { if (currentField?.type === 'radio') changeRadio('right') else if (currentField?.type === 'scale') changeScale('right') } else if (key.name === 'escape') { // If we can go back (history exists), escape navigates back // Otherwise, escape resets the current field if (onBack) { onBack() } else { resetCurrentField() } } else if (key.name === 'return') handleSubmit() else if (key.sequence === 'h' && onHistory) { // 'h' opens history screen (only when not in an input field) if (currentField?.type !== 'input') { onHistory() } } }) // ─── Form Submission ─────────────────────────────────────────────────────── /** * Build final configuration and trigger generation. * * Assembles background and foreground configs based on current type * selections and calls onComplete with the full AssetGeneratorConfig. */ const handleSubmit = () => { // Build background config based on selected type. let background: BackgroundConfig if (bgType === 0) { background = { type: 'color', color: { type: 'solid', color: bgColor } } } else if (bgType === 1) { background = { type: 'gradient', gradient: { type: 'linear', colors: ['#B3D9E8', '#004C6E'], angle: 180, }, } } else { background = { type: 'image', imagePath: './background.png' } } // Build foreground config based on selected type. let foreground: ForegroundConfig if (fgType === 0) { // Normalize font family to correct casing for API calls. const normalizedFont = normalizeFontFamily(fgFont) foreground = { type: 'text', text: fgText, fontFamily: normalizedFont, color: fgColor, fontSource: 'google', } } else if (fgType === 1) { foreground = { type: 'svg', svgPath: './icon.svg', color: fgColor } } else { foreground = { type: 'image', imagePath: './icon.png' } } // Determine output directory: use custom path or generate timestamped default. const finalOutputDir = outputPath.trim() ? resolvePath(outputPath.trim()) : getOutputDir() // Invoke completion callback with full configuration. onComplete({ appName, platforms: ['ios', 'android', 'web'] as Platform[], assetTypes: ['icon', 'splash', 'adaptive', 'favicon'] as AssetType[], background, foreground, outputDir: finalOutputDir, iconScale, splashScale, }) } // ─── Field Rendering ─────────────────────────────────────────────────────── // Label column width (longest label: "Background Color" = 16 chars). const labelWidth = 17 /** * Render a single field row based on its type. * * Handles three field types: * - Input: Text input with bracket notation [value]. * - Radio: Option buttons with ● selected / ○ unselected. * - Scale: Visual slider with ←→ adjustment. */ const renderField = (field: FieldDef, index: number) => { const isFocused = index === focusIndex const prefix = isFocused ? '▶ ' : ' ' const labelFg = isFocused ? colors.text : colors.textMuted if (field.type === 'radio' && field.options) { const selectedIndex = field.id === 'bgType' ? bgType : fgType return ( {prefix} {field.label.padEnd(labelWidth)} {field.options.map((opt, i) => ( {i === selectedIndex ? '● ' : '○ '} {opt} {' '} ))} ) } // Scale field - visual slider with ←→ navigation. if (field.type === 'scale') { const value = field.id === 'iconScale' ? iconScale : splashScale const { min = 0, max = 1 } = field const percentage = Math.round(value * 100) const sliderWidth = 20 const filled = Math.round(((value - min) / (max - min)) * sliderWidth) return ( {prefix} {field.label.padEnd(labelWidth)} {'['} {'━'.repeat(filled)} {'─'.repeat(sliderWidth - filled)} {']'} {` ${percentage}%`} ) } // Text input field - bracket notation [value] indicates editability. const value = getValue(field.id) const isColorField = field.id.includes('Color') const fieldWidth = 18 // Uniform width for bracket alignment. const showColorPreview = isColorField && isValidHexColor(value) return ( {prefix} {field.label.padEnd(labelWidth)} [ setValue(field.id, v)} width={fieldWidth} textColor={colors.text} cursorColor={colors.accent} /> ] {showColorPreview && ( )} ) } // ─── Render ──────────────────────────────────────────────────────────────── return ( {/* Form Fields */} {visibleFields.map((field, index) => renderField(field, index))} {/* Contextual Tip or Font Suggestions */} {currentField?.id === 'fgFont' ? ( fontSuggestions.length > 0 ? ( <> 💡 Matches ({fontSuggestions.length}): {' '} {truncate(fontSuggestions.join(', '), terminalWidth - 5)} ) : fgFont.length > 0 ? ( 💡 No matching fonts found ) : ( 💡 {currentField?.tip} ) ) : currentField?.id === 'iconScale' ? ( <> 💡 {currentField?.tip} {getIconScaleWarning(iconScale) && ( {getIconScaleWarning(iconScale)} )} ) : currentField?.id === 'splashScale' ? ( <> 💡 {currentField?.tip} {getSplashScaleWarning(splashScale) && ( {getSplashScaleWarning(splashScale)} )} ) : ( 💡 {currentField?.tip} )} {/* Live Preview - only shown on medium+ screens */} {showPreview && ( )} ) }