Files
OpenNoodl/dev-docs/tasks/phase-2/TASK-005-new-nodes/NODES-001-responsive-update/05-VISUAL-STATES-COMBO.md
2025-12-15 11:58:55 +01:00

576 lines
18 KiB
Markdown
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
# 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<string, Record<string, any>>;
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 (
<Tooltip content={`Set for ${currentState} on ${currentBreakpoint}`}>
<span className={css.ComboIndicator}>
{currentState} + {currentBreakpoint}
</span>
</Tooltip>
);
case 'state':
return (
<Tooltip content={`Set for ${currentState} state`}>
<span className={css.StateIndicator}> {currentState}</span>
</Tooltip>
);
case 'breakpoint':
return (
<Tooltip content={`Set for ${currentBreakpoint}`}>
<span className={css.BreakpointIndicator}> {currentBreakpoint}</span>
</Tooltip>
);
case 'base':
default:
if (currentState !== 'neutral' || currentBreakpoint !== 'desktop') {
return <span className={css.Inherited}>(inherited)</span>;
}
return null;
}
}
return (
<div className={css.Root}>
<label className={css.Label}>{label}</label>
<div className={css.InputContainer}>
{children}
{getIndicator()}
{valueSource !== 'base' && onReset && (
<button className={css.ResetButton} onClick={onReset}>
<Icon icon={IconName.Undo} size={12} />
</button>
)}
</div>
</div>
);
}
```
### 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<string, Record<string, any>>;
// 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.