# STYLE-002: Element Configs & Variants ## Overview Define default configurations and pre-built style variants for Noodl's core visual nodes. When users drag a Button, Group, Text, or Input onto the canvas, it should look good immediately and offer variant options via a simple dropdown. **Phase:** 8 (Styles Overhaul) **Priority:** HIGH (core feature of Phase 8) **Effort:** 16-20 hours **Risk:** Medium **Dependencies:** STYLE-001 (Token System) --- ## Background ### Current State - Visual nodes render with browser defaults (unstyled) - Users must manually style every element from scratch - No variant system for quick style switching - Existing "style variants" feature is underutilized (requires manual setup) ### Target State - Visual nodes have sensible, themed defaults on creation - Common components (Button, Input) have pre-built variants (Primary, Secondary, etc.) - Variants reference project tokens (change token โ†’ all variants update) - Users can create custom variants and save them --- ## Core Visual Nodes to Configure | Node | Default Variants | Notes | |------|------------------|-------| | **Button** | primary, secondary, outline, ghost, destructive, link | Most important | | **Group** | default, card, section, inset | Container patterns | | **Text** | body, heading-1 through heading-6, muted, label, code | Typography hierarchy | | **TextInput** | default, error | Form inputs | | **TextArea** | default, error | Multi-line input | | **Checkbox** | default | Form control | | **Radio Button** | default | Form control | | **Switch** | default | Toggle control | | **Image** | default, rounded, circle | Image display | --- ## Element Configurations ### Button Config ```typescript const ButtonConfig: ElementConfig = { nodeType: 'net.noodl.visual.button', // Default styling applied on node creation defaults: { // Layout defaults paddingTop: 'var(--space-2)', paddingBottom: 'var(--space-2)', paddingLeft: 'var(--space-4)', paddingRight: 'var(--space-4)', // Typography defaults fontSize: 'var(--text-sm)', fontWeight: 'var(--font-medium)', fontFamily: 'var(--font-sans)', // Border defaults borderRadius: 'var(--radius-md)', // Behavior cursor: 'pointer', // Default variant _variant: 'primary' }, // Size presets sizes: { sm: { paddingTop: 'var(--space-1)', paddingBottom: 'var(--space-1)', paddingLeft: 'var(--space-2)', paddingRight: 'var(--space-2)', fontSize: 'var(--text-xs)', }, md: { paddingTop: 'var(--space-2)', paddingBottom: 'var(--space-2)', paddingLeft: 'var(--space-4)', paddingRight: 'var(--space-4)', fontSize: 'var(--text-sm)', }, lg: { paddingTop: 'var(--space-3)', paddingBottom: 'var(--space-3)', paddingLeft: 'var(--space-6)', paddingRight: 'var(--space-6)', fontSize: 'var(--text-base)', }, xl: { paddingTop: 'var(--space-4)', paddingBottom: 'var(--space-4)', paddingLeft: 'var(--space-8)', paddingRight: 'var(--space-8)', fontSize: 'var(--text-lg)', } }, // Style variants variants: { primary: { backgroundColor: 'var(--primary)', color: 'var(--primary-foreground)', borderWidth: '0', boxShadow: 'var(--shadow-sm)', states: { hover: { backgroundColor: 'var(--primary-hover)', }, active: { transform: 'scale(0.98)', }, disabled: { opacity: '0.5', cursor: 'not-allowed', } } }, secondary: { backgroundColor: 'var(--secondary)', color: 'var(--secondary-foreground)', borderWidth: '0', boxShadow: 'var(--shadow-sm)', states: { hover: { backgroundColor: 'var(--secondary-hover)', }, active: { transform: 'scale(0.98)', }, disabled: { opacity: '0.5', cursor: 'not-allowed', } } }, outline: { backgroundColor: 'transparent', color: 'var(--foreground)', borderWidth: 'var(--border-1)', borderColor: 'var(--border)', borderStyle: 'solid', states: { hover: { backgroundColor: 'var(--accent)', color: 'var(--accent-foreground)', }, disabled: { opacity: '0.5', cursor: 'not-allowed', } } }, ghost: { backgroundColor: 'transparent', color: 'var(--foreground)', borderWidth: '0', states: { hover: { backgroundColor: 'var(--accent)', color: 'var(--accent-foreground)', }, disabled: { opacity: '0.5', cursor: 'not-allowed', } } }, destructive: { backgroundColor: 'var(--destructive)', color: 'var(--destructive-foreground)', borderWidth: '0', boxShadow: 'var(--shadow-sm)', states: { hover: { backgroundColor: 'var(--destructive-hover)', }, disabled: { opacity: '0.5', cursor: 'not-allowed', } } }, link: { backgroundColor: 'transparent', color: 'var(--primary)', borderWidth: '0', textDecoration: 'none', paddingLeft: '0', paddingRight: '0', states: { hover: { textDecoration: 'underline', } } } } }; ``` ### Group Config ```typescript const GroupConfig: ElementConfig = { nodeType: 'net.noodl.visual.group', defaults: { // Flexbox defaults display: 'flex', flexDirection: 'column', alignItems: 'stretch', // No default variant - groups start transparent _variant: 'default' }, variants: { default: { backgroundColor: 'transparent', padding: '0', borderWidth: '0', borderRadius: '0', }, card: { backgroundColor: 'var(--surface)', padding: 'var(--space-4)', borderWidth: 'var(--border-1)', borderColor: 'var(--border-subtle)', borderStyle: 'solid', borderRadius: 'var(--radius-lg)', boxShadow: 'var(--shadow-md)', }, section: { padding: 'var(--space-8)', }, inset: { backgroundColor: 'var(--muted)', padding: 'var(--space-4)', borderRadius: 'var(--radius-md)', }, 'flex-row': { flexDirection: 'row', alignItems: 'center', gap: 'var(--space-2)', }, 'flex-col': { flexDirection: 'column', gap: 'var(--space-2)', }, centered: { alignItems: 'center', justifyContent: 'center', } } }; ``` ### Text Config ```typescript const TextConfig: ElementConfig = { nodeType: 'net.noodl.visual.text', // ============================================================ // ๐Ÿ› BUG FIX: Text element default sizing // ============================================================ // ISSUE: Text elements default to 100% width in a way that // causes overflow when siblings are added. The element forces // full width instead of sharing space with siblings. // // ROOT CAUSE: Default width is set to '100%' with no flex-shrink, // causing the element to refuse to shrink when siblings exist. // // FIX: Set proper flex defaults so text elements participate // correctly in flex layout: // - flexShrink: '1' (allow shrinking) // - flexGrow: '0' (don't expand beyond content) // - width: 'auto' (size to content by default) // - minWidth: '0' (allow shrinking below content size if needed) // ============================================================ defaults: { // FIXED: Proper flex participation defaults width: 'auto', // Was: '100%' - caused overflow height: 'auto', // Content height (correct) flexShrink: '1', // NEW: Allow shrinking in flex container flexGrow: '0', // NEW: Don't expand beyond content minWidth: '0', // NEW: Allow shrinking below content // Typography defaults fontFamily: 'var(--font-sans)', fontSize: 'var(--text-base)', fontWeight: 'var(--font-normal)', lineHeight: 'var(--leading-normal)', color: 'var(--foreground)', // Default variant _variant: 'body' }, variants: { body: { fontSize: 'var(--text-base)', fontWeight: 'var(--font-normal)', lineHeight: 'var(--leading-normal)', color: 'var(--foreground)', }, 'heading-1': { fontSize: 'var(--text-4xl)', fontWeight: 'var(--font-bold)', lineHeight: 'var(--leading-tight)', color: 'var(--foreground)', letterSpacing: 'var(--tracking-tight)', }, 'heading-2': { fontSize: 'var(--text-3xl)', fontWeight: 'var(--font-semibold)', lineHeight: 'var(--leading-tight)', color: 'var(--foreground)', }, 'heading-3': { fontSize: 'var(--text-2xl)', fontWeight: 'var(--font-semibold)', lineHeight: 'var(--leading-snug)', color: 'var(--foreground)', }, 'heading-4': { fontSize: 'var(--text-xl)', fontWeight: 'var(--font-semibold)', lineHeight: 'var(--leading-snug)', color: 'var(--foreground)', }, 'heading-5': { fontSize: 'var(--text-lg)', fontWeight: 'var(--font-medium)', lineHeight: 'var(--leading-normal)', color: 'var(--foreground)', }, 'heading-6': { fontSize: 'var(--text-base)', fontWeight: 'var(--font-medium)', lineHeight: 'var(--leading-normal)', color: 'var(--foreground)', }, muted: { fontSize: 'var(--text-sm)', color: 'var(--muted-foreground)', }, label: { fontSize: 'var(--text-sm)', fontWeight: 'var(--font-medium)', color: 'var(--foreground)', }, small: { fontSize: 'var(--text-xs)', color: 'var(--muted-foreground)', }, code: { fontFamily: 'var(--font-mono)', fontSize: 'var(--text-sm)', backgroundColor: 'var(--muted)', padding: '2px 4px', borderRadius: 'var(--radius-sm)', }, lead: { fontSize: 'var(--text-xl)', color: 'var(--muted-foreground)', lineHeight: 'var(--leading-relaxed)', }, blockquote: { fontStyle: 'italic', borderLeftWidth: '4px', borderLeftColor: 'var(--border)', borderLeftStyle: 'solid', paddingLeft: 'var(--space-4)', color: 'var(--muted-foreground)', } } }; ``` ### TextInput Config ```typescript const TextInputConfig: ElementConfig = { nodeType: 'net.noodl.visual.textinput', defaults: { // Sizing width: '100%', height: 'auto', // Spacing paddingTop: 'var(--space-2)', paddingBottom: 'var(--space-2)', paddingLeft: 'var(--space-3)', paddingRight: 'var(--space-3)', // Typography fontFamily: 'var(--font-sans)', fontSize: 'var(--text-base)', color: 'var(--foreground)', // Border borderWidth: 'var(--border-1)', borderColor: 'var(--border)', borderStyle: 'solid', borderRadius: 'var(--radius-md)', // Background backgroundColor: 'var(--background)', // Default variant _variant: 'default' }, variants: { default: { borderColor: 'var(--border)', backgroundColor: 'var(--background)', states: { focus: { borderColor: 'var(--ring)', boxShadow: '0 0 0 2px var(--ring)', outline: 'none', }, disabled: { backgroundColor: 'var(--muted)', opacity: '0.5', cursor: 'not-allowed', }, placeholder: { color: 'var(--muted-foreground)', } } }, error: { borderColor: 'var(--destructive)', states: { focus: { borderColor: 'var(--destructive)', boxShadow: '0 0 0 2px var(--destructive)', } } } } }; ``` --- ## Implementation ### Phase 1: Config System Architecture (4-6 hrs) **Files to create:** ``` packages/noodl-editor/src/editor/src/models/ โ”œโ”€โ”€ ElementConfigs/ โ”‚ โ”œโ”€โ”€ ElementConfigModel.ts # Main config model โ”‚ โ”œโ”€โ”€ ElementConfigTypes.ts # TypeScript interfaces โ”‚ โ”œโ”€โ”€ configs/ โ”‚ โ”‚ โ”œโ”€โ”€ ButtonConfig.ts โ”‚ โ”‚ โ”œโ”€โ”€ GroupConfig.ts โ”‚ โ”‚ โ”œโ”€โ”€ TextConfig.ts โ”‚ โ”‚ โ”œโ”€โ”€ TextInputConfig.ts โ”‚ โ”‚ โ”œโ”€โ”€ CheckboxConfig.ts โ”‚ โ”‚ โ””โ”€โ”€ index.ts # Exports all configs โ”‚ โ”œโ”€โ”€ ElementConfigRegistry.ts # Registry for configs โ”‚ โ””โ”€โ”€ index.ts ``` **TypeScript Interfaces:** ```typescript interface ElementConfig { nodeType: string; defaults: Record; sizes?: Record>; variants: Record; } interface VariantConfig { [property: string]: string | StateConfig; states?: StateConfig; } interface StateConfig { hover?: Record; active?: Record; focus?: Record; disabled?: Record; placeholder?: Record; } interface ElementConfigRegistry { configs: Map; register(config: ElementConfig): void; get(nodeType: string): ElementConfig | undefined; getVariants(nodeType: string): string[]; applyDefaults(node: NodeModel): void; applyVariant(node: NodeModel, variantName: string): void; } ``` ### Phase 2: Node Creation Integration (4-5 hrs) **Files to modify:** ``` packages/noodl-editor/src/editor/src/models/ โ”œโ”€โ”€ NodeModel.ts # Apply defaults on creation โ”œโ”€โ”€ NodeGraphModel.ts # Hook into node creation packages/noodl-viewer-react/src/ โ”œโ”€โ”€ nodes/basic/Text.jsx # Fix defaults (BUG FIX) โ”œโ”€โ”€ nodes/controls/Button.jsx # Apply variant styles โ””โ”€โ”€ react-component-node.js # Variant resolution ``` **Tasks:** 1. Hook into node creation lifecycle 2. Apply config defaults when node is created 3. Store variant selection in node data 4. Apply variant styles at render time 5. **BUG FIX**: Fix Text element default sizing **Node Creation Flow:** ```typescript // In NodeGraphModel.ts or similar function createNode(nodeType: string, position: Point): NodeModel { const node = new NodeModel(nodeType, position); // Apply element config defaults const config = ElementConfigRegistry.get(nodeType); if (config) { ElementConfigRegistry.applyDefaults(node); } return node; } ``` ### Phase 3: Variant Selector UI (4-5 hrs) **Files to create/modify:** ``` packages/noodl-core-ui/src/components/inputs/ โ”œโ”€โ”€ VariantSelector/ โ”‚ โ”œโ”€โ”€ VariantSelector.tsx โ”‚ โ”œโ”€โ”€ VariantSelector.module.scss โ”‚ โ”œโ”€โ”€ VariantSelector.stories.tsx โ”‚ โ”œโ”€โ”€ VariantPreview.tsx # Visual preview of variant โ”‚ โ””โ”€โ”€ index.ts packages/noodl-editor/src/editor/src/views/panels/propertyeditor/ โ”œโ”€โ”€ VariantSection.tsx # Variant section in property panel ``` **Variant Selector UI:** ``` โ”Œโ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ” โ”‚ Variant: [Primary โ–ผ] โ”‚ โ”‚ โ”Œโ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ” โ”‚ โ”‚ โ”‚ โ— Primary [โ–ˆโ–ˆโ–ˆโ–ˆโ–ˆโ–ˆโ–ˆโ–ˆโ–ˆโ–ˆโ–ˆโ–ˆ] โ”‚ โ”‚ โ”‚ โ”‚ Secondary [โ–ˆโ–ˆโ–ˆโ–ˆโ–ˆโ–ˆโ–ˆโ–ˆโ–ˆโ–ˆโ–ˆโ–ˆ] โ”‚ โ”‚ โ”‚ โ”‚ Outline [โ–‘โ–‘โ–‘โ–‘โ–‘โ–‘โ–‘โ–‘โ–‘โ–‘โ–‘โ–‘] โ”‚ โ”‚ โ”‚ โ”‚ Ghost [ยทยทยทยทยทยทยทยทยทยทยทยท] โ”‚ โ”‚ โ”‚ โ”‚ Destructive[โ–ˆโ–ˆโ–ˆโ–ˆโ–ˆโ–ˆโ–ˆโ–ˆโ–ˆโ–ˆโ–ˆโ–ˆ] โ”‚ โ”‚ โ”‚ โ”‚ Link [____________] โ”‚ โ”‚ โ”‚ โ”‚ โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€ โ”‚ โ”‚ โ”‚ โ”‚ + Create Variant โ”‚ โ”‚ โ”‚ โ””โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”˜ โ”‚ โ””โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”˜ ``` ### Phase 4: State Handling (3-4 hrs) **Tasks:** 1. Implement hover state application 2. Implement active/pressed state 3. Implement focus state 4. Implement disabled state 5. Implement placeholder styling (inputs) **State Implementation:** ```typescript // In react-component-node.js or component wrapper function applyVariantStates( baseStyles: CSSProperties, variant: VariantConfig, states: { isHovered: boolean; isActive: boolean; isFocused: boolean; isDisabled: boolean } ): CSSProperties { let styles = { ...baseStyles }; if (states.isDisabled && variant.states?.disabled) { styles = { ...styles, ...variant.states.disabled }; } else { if (states.isHovered && variant.states?.hover) { styles = { ...styles, ...variant.states.hover }; } if (states.isActive && variant.states?.active) { styles = { ...styles, ...variant.states.active }; } if (states.isFocused && variant.states?.focus) { styles = { ...styles, ...variant.states.focus }; } } return styles; } ``` --- ## Text Element Bug Fix Details ### Current Behavior (Bug) ``` Parent Group (row layout) โ”œโ”€โ”€ Text "Hello" โ†’ Takes 100% width, pushes sibling off-screen โ””โ”€โ”€ Text "World" โ†’ Overflows to the right (not visible) ``` ### Expected Behavior (After Fix) ``` Parent Group (row layout) โ”œโ”€โ”€ Text "Hello" โ†’ Takes ~50% width (flex shrinks) โ””โ”€โ”€ Text "World" โ†’ Takes ~50% width (both visible) ``` ### Root Cause Analysis The Text node's default styling sets `width: 100%` without proper flex shrink behavior: ```javascript // Current (problematic) defaults in Text.jsx const defaultStyle = { width: '100%', // Forces full width height: 'auto', // Missing: flexShrink, flexGrow, minWidth }; ``` ### Fix Implementation ```javascript // Fixed defaults in Text.jsx const defaultStyle = { width: 'auto', // Changed from '100%' height: 'auto', flexShrink: 1, // Allow shrinking flexGrow: 0, // Don't expand beyond content minWidth: 0, // Allow shrinking below intrinsic size }; ``` ### Files to Modify ``` packages/noodl-viewer-react/src/nodes/basic/Text.jsx - Update defaultStyle object - Add flexShrink, flexGrow, minWidth defaults packages/noodl-runtime/src/nodes/basic/text.js (if exists) - Mirror changes for runtime consistency ``` ### Testing the Fix 1. Create a Group with row layout 2. Add two Text elements as children 3. **Before fix**: Second text overflows 4. **After fix**: Both texts share space equally --- ## Custom Variant Creation Users should be able to create their own variants: ### "Save as Variant" Flow ``` โ”Œโ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ” โ”‚ CREATE NEW VARIANT โ”‚ โ”œโ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”ค โ”‚ โ”‚ โ”‚ This button has custom styling. Save it as a reusable variant? โ”‚ โ”‚ โ”‚ โ”‚ Variant Name: [success-button ] โ”‚ โ”‚ โ”‚ โ”‚ Apply to: โ”‚ โ”‚ โ—‹ This project only โ”‚ โ”‚ โ— All projects (global) โ”‚ โ”‚ โ”‚ โ”‚ Preview: โ”‚ โ”‚ โ”Œโ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ” โ”‚ โ”‚ โ”‚ [ Success ] โ”‚ โ”‚ โ”‚ โ””โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”˜ โ”‚ โ”‚ โ”‚ โ”‚ [Cancel] [Save Variant] โ”‚ โ””โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”˜ ``` ### Storage ```typescript interface CustomVariant extends VariantConfig { name: string; nodeType: string; scope: 'project' | 'global'; createdAt: Date; } // Project-scoped variants stored in project file // Global variants stored in user preferences ``` --- ## Testing Strategy ### Unit Tests - Config registry operations - Variant style resolution - State style merging - Token reference resolution in variants ### Integration Tests - Node creation applies defaults - Variant change updates styles - State changes reflect visually - Custom variants persist ### Manual Testing Checklist - [ ] Create Button, verify styled by default - [ ] Change Button variant, see instant update - [ ] Create Group with "card" variant - [ ] Create Text with "heading-1" variant - [ ] **Test Text bug fix**: Two Text elements in row layout share space - [ ] Create custom variant and reuse it - [ ] Hover/active/disabled states work correctly --- ## Success Criteria - [ ] All listed nodes have default configs - [ ] Variant dropdown appears in property panel - [ ] Variants reference tokens correctly - [ ] State styles apply on interaction - [ ] Custom variant creation works - [ ] **Text element bug is fixed** - [ ] No regression in existing projects --- ## Dependencies **Blocked By:** - STYLE-001 (Token System) - variants reference tokens **Blocks:** - STYLE-004 (Property Panel UX) - uses variant selector --- *Last Updated: January 2026*