mirror of
https://github.com/The-Low-Code-Foundation/OpenNoodl.git
synced 2026-01-12 07:12:54 +01:00
819 lines
22 KiB
Markdown
819 lines
22 KiB
Markdown
# 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<string, string>;
|
|
sizes?: Record<string, Record<string, string>>;
|
|
variants: Record<string, VariantConfig>;
|
|
}
|
|
|
|
interface VariantConfig {
|
|
[property: string]: string | StateConfig;
|
|
states?: StateConfig;
|
|
}
|
|
|
|
interface StateConfig {
|
|
hover?: Record<string, string>;
|
|
active?: Record<string, string>;
|
|
focus?: Record<string, string>;
|
|
disabled?: Record<string, string>;
|
|
placeholder?: Record<string, string>;
|
|
}
|
|
|
|
interface ElementConfigRegistry {
|
|
configs: Map<string, ElementConfig>;
|
|
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*
|