feat(element-configs): MVP 1 Infrastructure - Types, Registry, ButtonConfig

- Created ElementConfigTypes.ts with complete type definitions
- Implemented ElementConfigRegistry singleton with full CRUD operations
- Added ButtonConfig with 6 variants (primary, secondary, outline, ghost, destructive, link)
- Added 4 size presets (sm, md, lg, xl) for buttons
- All types compile without errors
- Registry methods: register, get, applyDefaults, applyVariant, resolveStyles, validate
- Token-based styling with var(--token-name) references

Part of STYLE-002 MVP implementation.
Next: TextConfig + Text sizing bug fix.
This commit is contained in:
Tara West
2026-01-15 10:23:17 +01:00
parent 505de200ce
commit af1b5085b1
5 changed files with 1008 additions and 0 deletions

View File

@@ -0,0 +1,491 @@
/**
* ElementConfigRegistry
*
* Central registry for managing element configurations.
* Provides methods to register, retrieve, and apply element configs.
*
* @module noodl-editor/models/ElementConfigs
* @since 1.2.0
*/
import type {
ElementConfig,
VariantConfig,
SizeConfig,
RegisterConfigOptions,
ConfigValidationResult,
ApplyVariantParams,
ResolveStylesParams,
StyleResolutionResult,
CSSValue
} from './ElementConfigTypes';
/**
* Minimal node interface for applying configs
* This represents the shape we need from NodeModel
*/
interface NodeLike {
/** Node type identifier */
type: string;
/** Node unique ID */
id: string;
/** Node parameters/properties */
parameters: Record<string, any>;
}
/**
* Registry for element configurations
* Singleton pattern - use ElementConfigRegistry.instance
*/
export class ElementConfigRegistry {
private static _instance: ElementConfigRegistry;
private configs: Map<string, ElementConfig>;
private constructor() {
this.configs = new Map();
}
/**
* Get the singleton instance
*/
static get instance(): ElementConfigRegistry {
if (!ElementConfigRegistry._instance) {
ElementConfigRegistry._instance = new ElementConfigRegistry();
}
return ElementConfigRegistry._instance;
}
/**
* Register a new element configuration
*
* @param config - The element configuration to register
* @param options - Registration options
* @returns True if registered successfully, false if already exists and override is false
*
* @example
* ```typescript
* ElementConfigRegistry.instance.register(ButtonConfig);
* ```
*/
register(config: ElementConfig, options: RegisterConfigOptions = {}): boolean {
const { override = false, validate = true } = options;
// Validate if requested
if (validate) {
const validation = this.validate(config);
if (!validation.valid) {
console.error(`[ElementConfigRegistry] Invalid config for ${config.nodeType}:`, validation.errors);
return false;
}
if (validation.warnings.length > 0) {
console.warn(`[ElementConfigRegistry] Warnings for ${config.nodeType}:`, validation.warnings);
}
}
// Check if already exists
const exists = this.configs.has(config.nodeType);
if (exists && !override) {
console.warn(
`[ElementConfigRegistry] Config for ${config.nodeType} already exists. Use override: true to replace.`
);
return false;
}
// Register the config
this.configs.set(config.nodeType, config);
console.log(
`[ElementConfigRegistry] ${exists ? 'Updated' : 'Registered'} config for ${config.nodeType} ` +
`(${Object.keys(config.variants).length} variants)`
);
return true;
}
/**
* Get an element configuration by node type
*
* @param nodeType - The node type identifier
* @returns The element config, or undefined if not found
*
* @example
* ```typescript
* const config = ElementConfigRegistry.instance.get('net.noodl.visual.button');
* ```
*/
get(nodeType: string): ElementConfig | undefined {
return this.configs.get(nodeType);
}
/**
* Check if a config exists for a node type
*
* @param nodeType - The node type identifier
* @returns True if a config exists
*/
has(nodeType: string): boolean {
return this.configs.has(nodeType);
}
/**
* Get all variant names for a node type
*
* @param nodeType - The node type identifier
* @returns Array of variant names, or empty array if config not found
*
* @example
* ```typescript
* const variants = ElementConfigRegistry.instance.getVariants('net.noodl.visual.button');
* // Returns: ['primary', 'secondary', 'outline', 'ghost', 'destructive', 'link']
* ```
*/
getVariants(nodeType: string): string[] {
const config = this.configs.get(nodeType);
if (!config) return [];
return Object.keys(config.variants);
}
/**
* Get all size preset names for a node type
*
* @param nodeType - The node type identifier
* @returns Array of size names, or empty array if no sizes defined
*/
getSizes(nodeType: string): string[] {
const config = this.configs.get(nodeType);
if (!config || !config.sizes) return [];
return Object.keys(config.sizes);
}
/**
* Get a specific variant configuration
*
* @param nodeType - The node type identifier
* @param variantName - The variant name
* @returns The variant config, or undefined if not found
*/
getVariant(nodeType: string, variantName: string): VariantConfig | undefined {
const config = this.configs.get(nodeType);
if (!config) return undefined;
return config.variants[variantName];
}
/**
* Get a specific size configuration
*
* @param nodeType - The node type identifier
* @param sizeName - The size name
* @returns The size config, or undefined if not found
*/
getSize(nodeType: string, sizeName: string): SizeConfig | undefined {
const config = this.configs.get(nodeType);
if (!config || !config.sizes) return undefined;
return config.sizes[sizeName];
}
/**
* Apply default styles to a node
* This should be called when a new node is created
*
* @param node - The node model to apply defaults to
* @returns True if defaults were applied, false if no config found
*
* @example
* ```typescript
* // In NodeModel constructor or creation hook:
* ElementConfigRegistry.instance.applyDefaults(newNode);
* ```
*/
applyDefaults(node: NodeLike): boolean {
const config = this.configs.get(node.type);
if (!config) return false;
// Apply default CSS properties to node parameters
for (const [property, value] of Object.entries(config.defaults)) {
// Only apply if the property doesn't already have a value
if (node.parameters[property] === undefined) {
node.parameters[property] = value;
}
}
// Set default variant if defined in defaults
if (config.defaults['_variant'] && !node.parameters['_variant']) {
node.parameters['_variant'] = config.defaults['_variant'];
}
console.log(`[ElementConfigRegistry] Applied defaults to ${node.type} (node ${node.id})`);
return true;
}
/**
* Apply a variant to a node
*
* @param node - The node model to apply the variant to
* @param params - Variant application parameters
* @returns True if variant was applied, false if config or variant not found
*
* @example
* ```typescript
* ElementConfigRegistry.instance.applyVariant(buttonNode, {
* variantName: 'secondary',
* preserveUserOverrides: true
* });
* ```
*/
applyVariant(node: NodeLike, params: ApplyVariantParams | string): boolean {
const variantName = typeof params === 'string' ? params : params.variantName;
const size = typeof params === 'object' ? params.size : undefined;
const preserveUserOverrides = typeof params === 'object' ? params.preserveUserOverrides !== false : true;
const config = this.configs.get(node.type);
if (!config) {
console.warn(`[ElementConfigRegistry] No config found for ${node.type}`);
return false;
}
const variant = config.variants[variantName];
if (!variant) {
console.warn(`[ElementConfigRegistry] Variant "${variantName}" not found for ${node.type}`);
return false;
}
// Store user overrides if preserving
const userOverrides: Record<string, any> = {};
if (preserveUserOverrides) {
// Detect which properties were user-modified
// (properties not in defaults or previous variant)
const previousVariantName = node.parameters['_variant'];
const previousVariant = previousVariantName ? config.variants[previousVariantName] : null;
for (const key in node.parameters) {
if (key.startsWith('_')) continue; // Skip system properties
const isFromDefaults = config.defaults[key] !== undefined;
const isFromPreviousVariant = previousVariant && previousVariant[key] !== undefined;
if (!isFromDefaults && !isFromPreviousVariant) {
userOverrides[key] = node.parameters[key];
}
}
}
// Apply variant properties
for (const [property, value] of Object.entries(variant)) {
if (property === 'states') continue; // States are handled separately
if (typeof value === 'string') {
node.parameters[property] = value;
}
}
// Apply size if specified
if (size && config.sizes) {
const sizeConfig = config.sizes[size];
if (sizeConfig) {
for (const [property, value] of Object.entries(sizeConfig)) {
node.parameters[property] = value;
}
}
}
// Restore user overrides
if (preserveUserOverrides) {
for (const [property, value] of Object.entries(userOverrides)) {
node.parameters[property] = value;
}
}
// Store the variant name
node.parameters['_variant'] = variantName;
if (size) {
node.parameters['_size'] = size;
}
console.log(`[ElementConfigRegistry] Applied variant "${variantName}" to ${node.type} (node ${node.id})`);
return true;
}
/**
* Resolve complete styles for a node
* Merges defaults + variant + size + user overrides
*
* @param params - Resolution parameters
* @returns Resolved styles with metadata
*
* @example
* ```typescript
* const result = ElementConfigRegistry.instance.resolveStyles({
* nodeType: 'net.noodl.visual.button',
* variant: 'primary',
* size: 'md',
* userOverrides: { backgroundColor: '#custom' }
* });
* ```
*/
resolveStyles(params: ResolveStylesParams): StyleResolutionResult | null {
const { nodeType, variant, size, userOverrides = {} } = params;
const config = this.configs.get(nodeType);
if (!config) return null;
const styles: Record<string, CSSValue> = {};
const sources: Record<string, 'default' | 'variant' | 'size' | 'user'> = {};
// 1. Apply defaults
for (const [property, value] of Object.entries(config.defaults)) {
if (property.startsWith('_')) continue; // Skip system properties
styles[property] = value;
sources[property] = 'default';
}
// 2. Apply variant
let variantConfig: VariantConfig | undefined;
if (variant) {
variantConfig = config.variants[variant];
if (variantConfig) {
for (const [property, value] of Object.entries(variantConfig)) {
if (property === 'states') continue;
if (typeof value === 'string') {
styles[property] = value;
sources[property] = 'variant';
}
}
}
}
// 3. Apply size
if (size && config.sizes) {
const sizeConfig = config.sizes[size];
if (sizeConfig) {
for (const [property, value] of Object.entries(sizeConfig)) {
styles[property] = value;
sources[property] = 'size';
}
}
}
// 4. Apply user overrides (highest priority)
for (const [property, value] of Object.entries(userOverrides)) {
styles[property] = value;
sources[property] = 'user';
}
return {
styles: {
base: styles,
states: variantConfig?.states
},
appliedVariant: variant,
appliedSize: size,
hasUserOverrides: Object.keys(userOverrides).length > 0,
sources
};
}
/**
* Validate an element configuration
*
* @param config - The config to validate
* @returns Validation result with errors and warnings
*/
validate(config: ElementConfig): ConfigValidationResult {
const errors: string[] = [];
const warnings: string[] = [];
// Check required fields
if (!config.nodeType) {
errors.push('nodeType is required');
}
if (!config.defaults) {
errors.push('defaults object is required');
}
if (!config.variants || Object.keys(config.variants).length === 0) {
errors.push('At least one variant is required');
}
// Check variant structure
if (config.variants) {
for (const [variantName, variantConfig] of Object.entries(config.variants)) {
if (!variantConfig || typeof variantConfig !== 'object') {
errors.push(`Variant "${variantName}" must be an object`);
}
}
}
// Warnings for missing common properties
if (config.defaults && !config.defaults['_variant']) {
warnings.push('No default variant specified in defaults._variant');
}
return {
valid: errors.length === 0,
errors,
warnings
};
}
/**
* Get all registered node types
*
* @returns Array of registered node type identifiers
*/
getRegisteredNodeTypes(): string[] {
return Array.from(this.configs.keys());
}
/**
* Get count of registered configs
*
* @returns Number of registered configs
*/
getCount(): number {
return this.configs.size;
}
/**
* Clear all registered configs
* WARNING: This is mainly for testing, use with caution
*/
clear(): void {
this.configs.clear();
console.log('[ElementConfigRegistry] Cleared all configs');
}
/**
* Get a summary of the registry state
*
* @returns Summary object with counts and node types
*/
getSummary(): {
totalConfigs: number;
nodeTypes: string[];
configDetails: Array<{
nodeType: string;
variantCount: number;
sizeCount: number;
hasDescription: boolean;
}>;
} {
const nodeTypes = this.getRegisteredNodeTypes();
const configDetails = nodeTypes.map((nodeType) => {
const config = this.configs.get(nodeType)!;
return {
nodeType,
variantCount: Object.keys(config.variants).length,
sizeCount: config.sizes ? Object.keys(config.sizes).length : 0,
hasDescription: !!config.description
};
});
return {
totalConfigs: this.configs.size,
nodeTypes,
configDetails
};
}
}
// Export singleton instance for convenience
export const registry = ElementConfigRegistry.instance;

View File

@@ -0,0 +1,230 @@
/**
* ElementConfigTypes
*
* Type definitions for the Element Configuration system.
* This system provides default styling, variants, and size presets
* for Noodl's visual nodes (Button, Text, Group, etc.).
*
* @module noodl-editor/models/ElementConfigs
* @since 1.2.0
*/
/**
* CSS property values with design token references
* Values can be direct CSS values or CSS variable references like 'var(--token-name)'
*/
export type CSSValue = string;
/**
* State-specific style overrides for interactive elements
*/
export interface StateConfig {
/** Styles applied on hover state */
hover?: Record<string, CSSValue>;
/** Styles applied on active/pressed state */
active?: Record<string, CSSValue>;
/** Styles applied on focus state (inputs, buttons) */
focus?: Record<string, CSSValue>;
/** Styles applied when element is disabled */
disabled?: Record<string, CSSValue>;
/** Styles applied to placeholder text (inputs only) */
placeholder?: Record<string, CSSValue>;
}
/**
* A style variant configuration
* Defines the CSS properties and state-specific overrides for a variant
*/
export interface VariantConfig {
/** Base CSS properties for this variant */
[property: string]: CSSValue | StateConfig | undefined;
/** Optional state-specific overrides */
states?: StateConfig;
}
/**
* Size preset configuration
* Defines CSS property overrides for different size variations (sm, md, lg, xl)
*/
export interface SizeConfig {
/** CSS properties for this size */
[property: string]: CSSValue;
}
/**
* Complete element configuration
* Defines defaults, variants, and size presets for a visual node type
*/
export interface ElementConfig {
/** Noodl node type identifier (e.g., 'net.noodl.visual.button') */
nodeType: string;
/** Default CSS properties applied on node creation */
defaults: Record<string, CSSValue>;
/** Named style variants (e.g., 'primary', 'secondary', 'outline') */
variants: Record<string, VariantConfig>;
/** Optional size presets (e.g., 'sm', 'md', 'lg', 'xl') */
sizes?: Record<string, SizeConfig>;
/** Optional description for documentation */
description?: string;
/** Optional categories for grouping (e.g., ['button', 'form', 'input']) */
categories?: string[];
}
/**
* Resolved styles for a node
* Result of merging defaults + variant + size + user overrides
*/
export interface ResolvedStyles {
/** Base CSS properties (defaults + variant + size) */
base: Record<string, CSSValue>;
/** State-specific style overrides (if applicable) */
states?: StateConfig;
}
/**
* Parameters for applying a variant to a node
*/
export interface ApplyVariantParams {
/** The variant name to apply */
variantName: string;
/** Optional size to apply simultaneously */
size?: string;
/** Whether to preserve user overrides (default: true) */
preserveUserOverrides?: boolean;
}
/**
* Parameters for resolving styles for a node
*/
export interface ResolveStylesParams {
/** The node type identifier */
nodeType: string;
/** Current variant name */
variant?: string;
/** Current size name */
size?: string;
/** User-defined CSS overrides */
userOverrides?: Record<string, CSSValue>;
}
/**
* Result of style resolution with metadata
*/
export interface StyleResolutionResult {
/** Resolved CSS properties */
styles: ResolvedStyles;
/** Which variant was applied */
appliedVariant?: string;
/** Which size was applied */
appliedSize?: string;
/** Whether user overrides were present */
hasUserOverrides: boolean;
/** Source of each property (for debugging) */
sources?: Record<string, 'default' | 'variant' | 'size' | 'user'>;
}
/**
* Options for registering a new element config
*/
export interface RegisterConfigOptions {
/** Whether to override existing config with same nodeType */
override?: boolean;
/** Whether to validate the config structure */
validate?: boolean;
}
/**
* Validation result for element config
*/
export interface ConfigValidationResult {
/** Whether the config is valid */
valid: boolean;
/** Validation errors (if any) */
errors: string[];
/** Validation warnings (if any) */
warnings: string[];
}
/**
* Custom variant created by user
* Contains variant configuration plus metadata
*/
export interface CustomVariant {
/** User-defined variant name */
name: string;
/** Node type this variant applies to */
nodeType: string;
/** The actual variant configuration (styles and states) */
config: VariantConfig;
/** Scope: 'project' (this project only) or 'global' (all projects) */
scope: 'project' | 'global';
/** When the variant was created */
createdAt: Date;
/** Optional user description */
description?: string;
}
/**
* Event emitted when a variant is applied to a node
*/
export interface VariantAppliedEvent {
/** Node ID that was modified */
nodeId: string;
/** Node type */
nodeType: string;
/** Previous variant (if any) */
previousVariant?: string;
/** New variant applied */
newVariant: string;
/** Timestamp of the change */
timestamp: Date;
}
/**
* Event emitted when element config is registered/updated
*/
export interface ConfigRegisteredEvent {
/** Node type registered */
nodeType: string;
/** Number of variants in this config */
variantCount: number;
/** Whether this was an update to existing config */
isUpdate: boolean;
/** Timestamp of registration */
timestamp: Date;
}

View File

@@ -0,0 +1,250 @@
/**
* ButtonConfig
*
* Element configuration for Button nodes.
* Defines default styling, 6 variants, and 4 size presets.
*
* @module noodl-editor/models/ElementConfigs/configs
* @since 1.2.0
*/
import type { ElementConfig } from '../ElementConfigTypes';
/**
* Button element configuration
* Provides modern, accessible button styles with multiple variants
*/
export const ButtonConfig: ElementConfig = {
nodeType: 'net.noodl.visual.button',
description: 'Interactive button element with multiple style variants and sizes',
categories: ['button', 'interactive', 'form'],
// Default properties applied when a new Button is created
defaults: {
// Layout
paddingTop: 'var(--space-2)',
paddingBottom: 'var(--space-2)',
paddingLeft: 'var(--space-4)',
paddingRight: 'var(--space-4)',
// Typography
fontSize: 'var(--text-sm)',
fontWeight: 'var(--font-medium)',
fontFamily: 'var(--font-sans)',
textAlign: 'center',
lineHeight: 'var(--leading-none)',
// Border
borderRadius: 'var(--radius-md)',
borderWidth: '0',
borderStyle: 'solid',
// Display
display: 'inline-flex',
alignItems: 'center',
justifyContent: 'center',
cursor: 'pointer',
userSelect: 'none',
whiteSpace: 'nowrap',
// Transitions
transitionProperty: 'background-color, border-color, color, box-shadow, transform',
transitionDuration: '150ms',
transitionTimingFunction: 'cubic-bezier(0.4, 0, 0.2, 1)',
// Default variant
_variant: 'primary'
},
// Size presets
sizes: {
sm: {
paddingTop: 'var(--space-1)',
paddingBottom: 'var(--space-1)',
paddingLeft: 'var(--space-3)',
paddingRight: 'var(--space-3)',
fontSize: 'var(--text-xs)',
height: '32px'
},
md: {
paddingTop: 'var(--space-2)',
paddingBottom: 'var(--space-2)',
paddingLeft: 'var(--space-4)',
paddingRight: 'var(--space-4)',
fontSize: 'var(--text-sm)',
height: '40px'
},
lg: {
paddingTop: 'var(--space-3)',
paddingBottom: 'var(--space-3)',
paddingLeft: 'var(--space-6)',
paddingRight: 'var(--space-6)',
fontSize: 'var(--text-base)',
height: '48px'
},
xl: {
paddingTop: 'var(--space-4)',
paddingBottom: 'var(--space-4)',
paddingLeft: 'var(--space-8)',
paddingRight: 'var(--space-8)',
fontSize: 'var(--text-lg)',
height: '56px'
}
},
// Style variants
variants: {
// Primary: Solid background, high emphasis
primary: {
backgroundColor: 'var(--primary)',
color: 'var(--primary-foreground)',
borderWidth: '0',
boxShadow: 'var(--shadow-sm)',
states: {
hover: {
backgroundColor: 'var(--primary-hover)',
boxShadow: 'var(--shadow-md)'
},
active: {
transform: 'scale(0.98)',
boxShadow: 'var(--shadow-sm)'
},
disabled: {
opacity: '0.5',
cursor: 'not-allowed',
pointerEvents: 'none'
}
}
},
// Secondary: Subtle background, medium emphasis
secondary: {
backgroundColor: 'var(--secondary)',
color: 'var(--secondary-foreground)',
borderWidth: '0',
boxShadow: 'var(--shadow-sm)',
states: {
hover: {
backgroundColor: 'var(--secondary-hover)',
boxShadow: 'var(--shadow-md)'
},
active: {
transform: 'scale(0.98)',
boxShadow: 'var(--shadow-sm)'
},
disabled: {
opacity: '0.5',
cursor: 'not-allowed',
pointerEvents: 'none'
}
}
},
// Outline: Transparent background with border
outline: {
backgroundColor: 'transparent',
color: 'var(--foreground)',
borderWidth: 'var(--border-1)',
borderColor: 'var(--border)',
borderStyle: 'solid',
boxShadow: 'none',
states: {
hover: {
backgroundColor: 'var(--accent)',
color: 'var(--accent-foreground)',
borderColor: 'var(--accent)'
},
active: {
transform: 'scale(0.98)'
},
disabled: {
opacity: '0.5',
cursor: 'not-allowed',
pointerEvents: 'none'
}
}
},
// Ghost: Minimal style, subtle hover
ghost: {
backgroundColor: 'transparent',
color: 'var(--foreground)',
borderWidth: '0',
boxShadow: 'none',
states: {
hover: {
backgroundColor: 'var(--accent)',
color: 'var(--accent-foreground)'
},
active: {
transform: 'scale(0.98)'
},
disabled: {
opacity: '0.5',
cursor: 'not-allowed',
pointerEvents: 'none'
}
}
},
// Destructive: For dangerous actions (delete, remove, etc.)
destructive: {
backgroundColor: 'var(--destructive)',
color: 'var(--destructive-foreground)',
borderWidth: '0',
boxShadow: 'var(--shadow-sm)',
states: {
hover: {
backgroundColor: 'var(--destructive-hover)',
boxShadow: 'var(--shadow-md)'
},
active: {
transform: 'scale(0.98)',
boxShadow: 'var(--shadow-sm)'
},
disabled: {
opacity: '0.5',
cursor: 'not-allowed',
pointerEvents: 'none'
}
}
},
// Link: Text-only style, no background
link: {
backgroundColor: 'transparent',
color: 'var(--primary)',
borderWidth: '0',
boxShadow: 'none',
textDecoration: 'none',
paddingLeft: '0',
paddingRight: '0',
height: 'auto',
states: {
hover: {
textDecoration: 'underline',
color: 'var(--primary-hover)'
},
active: {
color: 'var(--primary)'
},
disabled: {
opacity: '0.5',
cursor: 'not-allowed',
pointerEvents: 'none'
}
}
}
}
};

View File

@@ -0,0 +1,19 @@
/**
* Element Configs
*
* Pre-built element configurations for Noodl's visual nodes.
* Import and register these configs to enable default styling and variants.
*
* @module noodl-editor/models/ElementConfigs/configs
* @since 1.2.0
*/
export { ButtonConfig } from './ButtonConfig';
// TextConfig will be added next
// export { TextConfig } from './TextConfig';
// Other configs to be implemented:
// export { GroupConfig } from './GroupConfig';
// export { TextInputConfig } from './TextInputConfig';
// export { ImageConfig } from './ImageConfig';

View File

@@ -0,0 +1,18 @@
/**
* ElementConfigs
*
* System for managing default configurations, style variants, and size presets
* for Noodl's visual nodes (Button, Text, Group, Input, etc.).
*
* @module noodl-editor/models/ElementConfigs
* @since 1.2.0
*/
// Export all types
export * from './ElementConfigTypes';
// Export registry
export { ElementConfigRegistry, registry } from './ElementConfigRegistry';
// Configs will be exported once implemented
// export * from './configs';