# Phase 5: Visual States + Breakpoints Combo ## Overview Enable granular control where users can define values for specific combinations of visual state AND breakpoint. For example: "button hover state on tablet" can have different padding than "button hover state on desktop". **Estimate:** 2 days **Dependencies:** Phases 1-4 ## Goals 1. Add `stateBreakpointParameters` storage for combined state+breakpoint values 2. Implement resolution hierarchy with combo values at highest priority 3. Update property panel UI to show combo editing option 4. Ensure runtime correctly resolves combo values ## When This Is Useful Without combo support: - Button hover padding is `20px` (all breakpoints) - Button tablet padding is `16px` (all states) - When hovering on tablet → ambiguous! Which wins? With combo support: - Can explicitly set: "button hover ON tablet = `18px`" - Clear, deterministic resolution ## Data Model ```javascript { parameters: { paddingLeft: '24px' // base }, stateParameters: { hover: { paddingLeft: '28px' } // hover state (all breakpoints) }, breakpointParameters: { tablet: { paddingLeft: '16px' } // tablet (all states) }, // NEW: Combined state + breakpoint stateBreakpointParameters: { 'hover:tablet': { paddingLeft: '20px' }, // hover ON tablet 'hover:phone': { paddingLeft: '14px' }, // hover ON phone 'pressed:tablet': { paddingLeft: '18px' } // pressed ON tablet } } ``` ## Resolution Hierarchy From highest to lowest priority: ``` 1. stateBreakpointParameters['hover:tablet'] // Most specific ↓ (if undefined) 2. stateParameters['hover'] // State-specific ↓ (if undefined) 3. breakpointParameters['tablet'] // Breakpoint-specific ↓ (if undefined, cascade to larger breakpoints) 4. parameters // Base value ↓ (if undefined) 5. variant values (same hierarchy) ↓ (if undefined) 6. type default ``` ## Implementation Steps ### Step 1: Extend NodeGraphNode Model **File:** `packages/noodl-editor/src/editor/src/models/nodegraphmodel/NodeGraphNode.ts` ```typescript export class NodeGraphNode { // ... existing properties // NEW stateBreakpointParameters: Record>; constructor(args) { // ... existing initialization this.stateBreakpointParameters = args.stateBreakpointParameters || {}; } // NEW methods getStateBreakpointKey(state: string, breakpoint: string): string { return `${state}:${breakpoint}`; } hasStateBreakpointParameter(name: string, state: string, breakpoint: string): boolean { const key = this.getStateBreakpointKey(state, breakpoint); return this.stateBreakpointParameters?.[key]?.[name] !== undefined; } getStateBreakpointParameter(name: string, state: string, breakpoint: string): any { const key = this.getStateBreakpointKey(state, breakpoint); return this.stateBreakpointParameters?.[key]?.[name]; } setStateBreakpointParameter( name: string, value: any, state: string, breakpoint: string, args?: any ): void { const key = this.getStateBreakpointKey(state, breakpoint); if (!this.stateBreakpointParameters) { this.stateBreakpointParameters = {}; } if (!this.stateBreakpointParameters[key]) { this.stateBreakpointParameters[key] = {}; } const oldValue = this.stateBreakpointParameters[key][name]; if (value === undefined) { delete this.stateBreakpointParameters[key][name]; // Clean up empty objects if (Object.keys(this.stateBreakpointParameters[key]).length === 0) { delete this.stateBreakpointParameters[key]; } } else { this.stateBreakpointParameters[key][name] = value; } this.notifyListeners('parametersChanged', { name, value, state, breakpoint, combo: true }); // Undo support if (args?.undo) { const undo = typeof args.undo === 'object' ? args.undo : UndoQueue.instance; undo.push({ label: args.label || `change ${name} for ${state} on ${breakpoint}`, do: () => this.setStateBreakpointParameter(name, value, state, breakpoint), undo: () => this.setStateBreakpointParameter(name, oldValue, state, breakpoint) }); } } // Updated getParameter with full resolution getParameter(name: string, args?: { state?: string; breakpoint?: string }): any { const state = args?.state; const breakpoint = args?.breakpoint; const defaultBreakpoint = ProjectModel.instance.getBreakpointSettings().defaultBreakpoint; // 1. Check state + breakpoint combo (most specific) if (state && state !== 'neutral' && breakpoint && breakpoint !== defaultBreakpoint) { const comboValue = this.getStateBreakpointParameter(name, state, breakpoint); if (comboValue !== undefined) return comboValue; } // 2. Check state-specific value if (state && state !== 'neutral') { if (this.stateParameters?.[state]?.[name] !== undefined) { return this.stateParameters[state][name]; } } // 3. Check breakpoint-specific value (with cascade) if (breakpoint && breakpoint !== defaultBreakpoint) { const breakpointValue = this.getBreakpointParameterWithCascade(name, breakpoint); if (breakpointValue !== undefined) return breakpointValue; } // 4. Check base parameters if (this.parameters[name] !== undefined) { return this.parameters[name]; } // 5. Check variant (with same hierarchy) if (this.variant) { return this.variant.getParameter(name, args); } // 6. Type default const port = this.getPort(name, 'input'); return port?.default; } // Extend toJSON toJSON(): object { return { ...existingFields, stateBreakpointParameters: this.stateBreakpointParameters }; } } ``` ### Step 2: Extend ModelProxy **File:** `packages/noodl-editor/src/editor/src/views/panels/propertyeditor/models/modelProxy.ts` ```typescript export class ModelProxy { // ... existing properties getParameter(name: string) { const source = this.editMode === 'variant' ? this.model.variant : this.model; const port = this.model.getPort(name, 'input'); const state = this.visualState; const breakpoint = this.breakpoint; const defaultBreakpoint = ProjectModel.instance.getBreakpointSettings().defaultBreakpoint; // Check if both state and breakpoint are set (combo scenario) const hasState = state && state !== 'neutral'; const hasBreakpoint = breakpoint && breakpoint !== defaultBreakpoint; // For combo: only check if BOTH the property allows states AND breakpoints if (hasState && hasBreakpoint && port?.allowVisualStates && port?.allowBreakpoints) { const comboValue = source.getStateBreakpointParameter?.(name, state, breakpoint); if (comboValue !== undefined) return comboValue; } // ... existing resolution logic return source.getParameter(name, { state, breakpoint }); } setParameter(name: string, value: any, args: any = {}) { const port = this.model.getPort(name, 'input'); const target = this.editMode === 'variant' ? this.model.variant : this.model; const state = this.visualState; const breakpoint = this.breakpoint; const defaultBreakpoint = ProjectModel.instance.getBreakpointSettings().defaultBreakpoint; const hasState = state && state !== 'neutral'; const hasBreakpoint = breakpoint && breakpoint !== defaultBreakpoint; // If BOTH state and breakpoint are active, and property supports both if (hasState && hasBreakpoint && port?.allowVisualStates && port?.allowBreakpoints) { target.setStateBreakpointParameter(name, value, state, breakpoint, { ...args, undo: args.undo }); return; } // If only breakpoint (and property supports it) if (hasBreakpoint && port?.allowBreakpoints) { target.setBreakpointParameter(name, value, breakpoint, { ...args, undo: args.undo }); return; } // ... existing parameter setting logic (state or base) args.state = state; target.setParameter(name, value, args); } // NEW: Check if current value is from combo isComboValue(name: string): boolean { if (!this.visualState || this.visualState === 'neutral') return false; if (!this.breakpoint || this.breakpoint === 'desktop') return false; const source = this.editMode === 'variant' ? this.model.variant : this.model; return source.hasStateBreakpointParameter?.(name, this.visualState, this.breakpoint) || false; } // NEW: Get info about where current value comes from getValueSource(name: string): 'combo' | 'state' | 'breakpoint' | 'base' { const source = this.editMode === 'variant' ? this.model.variant : this.model; const state = this.visualState; const breakpoint = this.breakpoint; if (state && state !== 'neutral' && breakpoint && breakpoint !== 'desktop') { if (source.hasStateBreakpointParameter?.(name, state, breakpoint)) { return 'combo'; } } if (state && state !== 'neutral') { if (source.stateParameters?.[state]?.[name] !== undefined) { return 'state'; } } if (breakpoint && breakpoint !== 'desktop') { if (source.hasBreakpointParameter?.(name, breakpoint)) { return 'breakpoint'; } } return 'base'; } } ``` ### Step 3: Update Property Panel UI **File:** Update property row to show combo indicators ```tsx // In PropertyPanelRow or equivalent export function PropertyPanelRow({ label, children, isBreakpointAware, allowsVisualStates, valueSource, // 'combo' | 'state' | 'breakpoint' | 'base' currentState, currentBreakpoint, onReset }: PropertyPanelRowProps) { function getIndicator() { switch (valueSource) { case 'combo': return ( ● {currentState} + {currentBreakpoint} ); case 'state': return ( ● {currentState} ); case 'breakpoint': return ( ● {currentBreakpoint} ); case 'base': default: if (currentState !== 'neutral' || currentBreakpoint !== 'desktop') { return (inherited); } return null; } } return (
{children} {getIndicator()} {valueSource !== 'base' && onReset && ( )}
); } ``` ### Step 4: Update Runtime Resolution **File:** `packages/noodl-runtime/src/nodes/nodebase.js` ```javascript { getResolvedParameterValue(name) { const port = this.getPort ? this.getPort(name, 'input') : null; const currentBreakpoint = breakpointManager.getCurrentBreakpoint(); const currentState = this._internal?.currentVisualState || 'default'; const defaultBreakpoint = breakpointManager.settings?.defaultBreakpoint || 'desktop'; // 1. Check combo value (state + breakpoint) if (port?.allowVisualStates && port?.allowBreakpoints) { if (currentState !== 'default' && currentBreakpoint !== defaultBreakpoint) { const comboKey = `${currentState}:${currentBreakpoint}`; const comboValue = this._model.stateBreakpointParameters?.[comboKey]?.[name]; if (comboValue !== undefined) return comboValue; } } // 2. Check state-specific value if (port?.allowVisualStates && currentState !== 'default') { const stateValue = this._model.stateParameters?.[currentState]?.[name]; if (stateValue !== undefined) return stateValue; } // 3. Check breakpoint-specific value (with cascade) if (port?.allowBreakpoints && currentBreakpoint !== defaultBreakpoint) { const breakpointValue = this.getBreakpointValueWithCascade(name, currentBreakpoint); if (breakpointValue !== undefined) return breakpointValue; } // 4. Base parameters return this.getParameterValue(name); }, getBreakpointValueWithCascade(name, breakpoint) { // Check current breakpoint if (this._model.breakpointParameters?.[breakpoint]?.[name] !== undefined) { return this._model.breakpointParameters[breakpoint][name]; } // Cascade const inheritanceChain = breakpointManager.getInheritanceChain(breakpoint); for (const bp of inheritanceChain.reverse()) { if (this._model.breakpointParameters?.[bp]?.[name] !== undefined) { return this._model.breakpointParameters[bp][name]; } } return undefined; } } ``` ### Step 5: Extend VariantModel (Optional) If we want variants to also support combo values: **File:** `packages/noodl-editor/src/editor/src/models/VariantModel.ts` ```typescript export class VariantModel extends Model { // ... existing properties stateBreakpointParameters: Record>; // Add similar methods as NodeGraphNode: // - hasStateBreakpointParameter // - getStateBreakpointParameter // - setStateBreakpointParameter // Update getParameter to include combo resolution getParameter(name: string, args?: { state?: string; breakpoint?: string }): any { const state = args?.state; const breakpoint = args?.breakpoint; // 1. Check combo if (state && state !== 'neutral' && breakpoint && breakpoint !== 'desktop') { const comboKey = `${state}:${breakpoint}`; if (this.stateBreakpointParameters?.[comboKey]?.[name] !== undefined) { return this.stateBreakpointParameters[comboKey][name]; } } // ... rest of resolution hierarchy } } ``` ### Step 6: Update Serialization **File:** `packages/noodl-editor/src/editor/src/models/nodegraphmodel/NodeGraphNode.ts` ```typescript // In toJSON() toJSON(): object { const json: any = { id: this.id, type: this.type.name, parameters: this.parameters, // ... other fields }; // Only include if not empty if (this.stateParameters && Object.keys(this.stateParameters).length > 0) { json.stateParameters = this.stateParameters; } if (this.breakpointParameters && Object.keys(this.breakpointParameters).length > 0) { json.breakpointParameters = this.breakpointParameters; } if (this.stateBreakpointParameters && Object.keys(this.stateBreakpointParameters).length > 0) { json.stateBreakpointParameters = this.stateBreakpointParameters; } return json; } // In fromJSON / constructor static fromJSON(json) { return new NodeGraphNode({ ...json, stateBreakpointParameters: json.stateBreakpointParameters || {} }); } ``` ## Files to Modify | File | Changes | |------|---------| | `packages/noodl-editor/src/editor/src/models/nodegraphmodel/NodeGraphNode.ts` | Add stateBreakpointParameters field and methods | | `packages/noodl-editor/src/editor/src/views/panels/propertyeditor/models/modelProxy.ts` | Handle combo context in get/setParameter | | `packages/noodl-editor/src/editor/src/views/panels/propertyeditor/DataTypes/Ports.ts` | Pass combo info to property rows | | `packages/noodl-runtime/src/nodes/nodebase.js` | Add combo resolution to getResolvedParameterValue | | `packages/noodl-runtime/src/models/nodemodel.js` | Add stateBreakpointParameters storage | ## Files to Create None - this phase extends existing files. ## Testing Checklist - [ ] Can set combo value (e.g., hover + tablet) - [ ] Combo value takes priority over individual state/breakpoint values - [ ] When state OR breakpoint changes, combo value is no longer used (falls through to next priority) - [ ] Combo values are saved to project JSON - [ ] Combo values are loaded from project JSON - [ ] UI shows correct indicator for combo values - [ ] Reset button clears combo value correctly - [ ] Runtime applies combo values correctly when both conditions match - [ ] Undo/redo works for combo value changes ## Success Criteria 1. ✅ Can define "hover on tablet" as distinct from "hover" and "tablet" 2. ✅ Clear UI indication of what level value is set at 3. ✅ Values fall through correctly when combo doesn't match 4. ✅ Runtime correctly identifies when combo conditions are met ## Gotchas & Notes 1. **Complexity**: This adds significant complexity. Consider if this is really needed, or if the simpler "state values apply across all breakpoints" is sufficient. 2. **UI Clarity**: The UI needs to clearly communicate which level a value is set at. Consider using different colors: - Purple dot: combo value (state + breakpoint) - Blue dot: state value only - Green dot: breakpoint value only - Gray/no dot: base value 3. **Property Support**: Only properties that have BOTH `allowVisualStates: true` AND `allowBreakpoints: true` can have combo values. In practice, this might be a small subset (mostly spacing properties for interactive elements). 4. **Variant Complexity**: If variants also support combos, the full hierarchy becomes: - Instance combo → Instance state → Instance breakpoint → Instance base - → Variant combo → Variant state → Variant breakpoint → Variant base - → Type default This is 9 levels! Consider if variant combo support is worth it. 5. **Performance**: With 4 breakpoints × 4 states × N properties, the parameter space grows quickly. Make sure resolution is efficient. ## Alternative: Simpler Approach If combo complexity is too high, consider this simpler alternative: **States inherit from breakpoint, not base:** ``` Current: state value = same across all breakpoints Alternative: state value = applied ON TOP OF current breakpoint value ``` Example: ```javascript // Base: paddingLeft = 24px // Tablet: paddingLeft = 16px // Hover state: paddingLeft = +4px (relative) // Result: // Desktop hover = 24 + 4 = 28px // Tablet hover = 16 + 4 = 20px ``` This avoids needing explicit combo values but requires supporting relative/delta values for states, which has its own complexity.